diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index e51cea15..9a8f9756 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, type ReactNode } from 'react'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -13,6 +13,8 @@ import { apiFetch, withDeploySession } from '@/lib/api'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; +import { useIsMobile } from '@/hooks/use-is-mobile'; +import { Masthead } from '@/components/mobile/mobile-ui'; import { CategorySidebar } from '@/components/appstore/CategorySidebar'; import { FeaturedHero } from '@/components/appstore/FeaturedHero'; import { TemplateTile } from '@/components/appstore/TemplateTile'; @@ -31,9 +33,12 @@ interface PortInUseInfo { interface AppStoreViewProps { onDeploySuccess: (stackName: string) => void; + /** Notifications + more-menu cluster for the mobile masthead, rehomed from the dropped TopBar. */ + headerActions?: ReactNode; } -export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { +export function AppStoreView({ onDeploySuccess, headerActions }: AppStoreViewProps) { + const isMobile = useIsMobile(); const { can } = useAuth(); const { activeNode } = useNodes(); const { runWithLog } = useDeployFeedback(); @@ -280,19 +285,107 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { return conflicts; }, [selectedTemplate, portVars, portsInUse]); - return ( + const searchInput = ( +
+ + { setSearchQuery(e.target.value); }} + /> +
+ ); + + const contentBody = loading ? ( +
+ +
+ ) : ( +
+ {featuredTemplate && ( + featuredTemplate.logo && setImgErrors(prev => ({ ...prev, [featuredTemplate.logo!]: true }))} + /> + )} + + {gridTemplates.length > 0 ? ( +
+ {gridTemplates.map((t, idx) => ( + t.logo && setImgErrors(prev => ({ ...prev, [t.logo!]: true }))} + /> + ))} +
+ ) : !featuredTemplate ? ( +
+ + {templates.length === 0 ? ( +

Registry returned no templates. Check your registry URL in Settings.

+ ) : ( +

No apps found matching "{searchQuery}"

+ )} +
+ ) : null} +
+ ); + + // The phone view is search + a single-column list of every matching app: + // no featured hero and no category chips (the featured app is folded into + // the list so it is not dropped). + const mobileContent = loading ? ( +
+ +
+ ) : filtered.length > 0 ? ( +
+ {filtered.map((t, idx) => ( + t.logo && setImgErrors(prev => ({ ...prev, [t.logo!]: true }))} + /> + ))} +
+ ) : ( +
+ + {templates.length === 0 ? ( +

Registry returned no templates. Check your registry URL in Settings.

+ ) : ( +

No apps found matching "{searchQuery}"

+ )} +
+ ); + + const layout = isMobile ? ( +
+ 1 ? `${categoryEntries.length - 1} categories` : 'self-hosted templates'} + right={headerActions} + /> +
{searchInput}
+
{mobileContent}
+
+ ) : (
-
- - { setSearchQuery(e.target.value); }} - /> -
+ {searchInput} {filtered.length} app{filtered.length !== 1 ? 's' : ''} @@ -307,49 +400,14 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { /> )} - - {loading ? ( -
- -
- ) : ( -
- {featuredTemplate && ( - featuredTemplate.logo && setImgErrors(prev => ({ ...prev, [featuredTemplate.logo!]: true }))} - /> - )} - - {gridTemplates.length > 0 ? ( -
- {gridTemplates.map((t, idx) => ( - t.logo && setImgErrors(prev => ({ ...prev, [t.logo!]: true }))} - /> - ))} -
- ) : !featuredTemplate ? ( -
- - {templates.length === 0 ? ( -

Registry returned no templates. Check your registry URL in Settings.

- ) : ( -

No apps found matching "{searchQuery}"

- )} -
- ) : null} -
- )} -
+ {contentBody}
+
+ ); + + return ( + <> + {layout} )} - + ); } diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx index d41d0257..7a3fc3cb 100644 --- a/frontend/src/components/AuditLogView.tsx +++ b/frontend/src/components/AuditLogView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, Fragment, useMemo } from 'react'; +import { useState, useEffect, useCallback, Fragment, useMemo, type ReactNode } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -12,6 +12,8 @@ import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw, Download, Che import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { useLicense } from '@/context/LicenseContext'; +import { useIsMobile } from '@/hooks/use-is-mobile'; +import { Masthead, MobileSubTabs } from '@/components/mobile/mobile-ui'; type AnomalyFlag = 'unusual_hour' | 'new_ip' | 'first_seen_actor'; @@ -109,7 +111,13 @@ function isExpiredSession(err: unknown): boolean { return err instanceof Error && err.message === 'Unauthorized'; } -export function AuditLogView() { +interface AuditLogViewProps { + /** Notifications + more-menu cluster for the mobile masthead, rehomed from the dropped TopBar. */ + headerActions?: ReactNode; +} + +export function AuditLogView({ headerActions }: AuditLogViewProps = {}) { + const isMobile = useIsMobile(); // Community gets the recent-activity stream; CSV/JSON export, the stat // tiles, and per-row anomaly annotation are paid. The backend clamps the // list to a 14-day window for unpaid tiers, so this flag only governs the @@ -260,6 +268,65 @@ export function AuditLogView() { return groups; }, [entries]); + if (isMobile) { + // Derive a health-style state word from the (paid) failure-rate signal, + // matching the Home/Fleet/Security masthead standard. + const auditSeverity = isPaid ? stats?.failure_rate.severity : undefined; + const auditState = !auditSeverity ? 'Activity' : auditSeverity === 'alert' ? 'Alerts' : auditSeverity === 'warn' ? 'Review' : 'Healthy'; + const auditTone = auditSeverity === 'alert' ? 'destructive' : auditSeverity === 'warn' ? 'warning' : auditSeverity === 'ok' ? 'success' : 'brand'; + return ( +
+ + +
+
+ +
+ {view === 'stream' ? ( + + ) : ( +
+

Table view

+

+ The columnar table reads best on a larger screen. The Stream tab shows the same activity, reflowed for mobile. +

+
+ )} +
+
+ ); + } + return (
@@ -488,7 +555,7 @@ function StreamView({ stats, showStats, loading, groups, now, page, totalPages, return (
{showStats && ( -
+
{tiles.length === 0 ? ( Array.from({ length: 4 }).map((_, i) => (
diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index dd7c89ee..97b8f56a 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -6,6 +6,8 @@ import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; +import { useIsMobile } from '@/hooks/use-is-mobile'; +import { Masthead, Kicker } from '@/components/mobile/mobile-ui'; import { ImageSourceMenu } from './ImageSourceMenu'; import type { ScheduledTask } from '@/types/scheduling'; @@ -39,7 +41,7 @@ interface UpdatePreview { changelog: string | null; } -interface StackCard { +export interface StackCard { stack: string; nodeId: number; preview: UpdatePreview | null; @@ -367,7 +369,101 @@ function NodeGroupSection({ ); } -function AutoUpdateReadinessContent() { +// --- mobile ( void }) { + const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, autoUpdateEnabled } = card; + const failed = previewLoaded && preview === null; + const blocked = preview?.summary.blocked ?? false; + const bump = preview?.summary.semver_bump ?? 'none'; + const nextRun = scheduledTask?.next_run_at ?? null; + const changelog = preview?.changelog ?? 'No changelog available from the registry yet.'; + const dot = changelog.indexOf('.'); + const lead = dot > 0 ? changelog.slice(0, dot + 1) : ''; + const rest = dot > 0 ? changelog.slice(dot + 1) : changelog; + + return ( +
+
+
+ stack +
{stack}
+
+
+ {!autoUpdateEnabled && ( + + Auto: Off + + )} + {previewLoaded && preview && } +
+
+ + {!previewLoaded ? ( +
Checking registry...
+ ) : failed ? ( +
Preview failed. Registry may be unreachable.
+ ) : ( + <> + {preview!.summary.update_kind === 'digest' ? ( +
+ {preview!.summary.current_tag} + Rebuild available +
+ ) : ( + + )} +
{preview!.summary.primary_image ?? '-'}
+
+ {lead && {lead}}{rest} +
+
+ + {nextRun ? <>{formatClock(nextRun)} · {formatRelative(nextRun)} : (blocked ? 'Held for review' : 'No schedule')} + + +
+ + )} +
+ ); +} + +function MobileNodeSection({ group, onApply }: { group: NodeGroup; onApply: (stack: string, nodeId: number) => void }) { + return ( +
+
+ {group.nodeName} + {group.nodeType} + {group.cards.length} {group.cards.length === 1 ? 'stack' : 'stacks'} +
+
+ {group.cards.map(card => ( + + ))} +
+
+ ); +} + +interface AutoUpdateReadinessProps { + /** Notifications + more-menu cluster for the mobile masthead, rehomed from the dropped TopBar. */ + headerActions?: ReactNode; +} + +function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) { + const isMobile = useIsMobile(); const { nodes } = useNodes(); const [groups, setGroups] = useState([]); const [reachableNodeCount, setReachableNodeCount] = useState(null); @@ -618,6 +714,45 @@ function AutoUpdateReadinessContent() { && onlineNodeCount > 0 && reachableNodeCount < onlineNodeCount; + if (isMobile) { + return ( +
+ 0} + meta={total > 0 ? `${ready} ready · ${total - ready} in review` : 'all stacks current'} + right={headerActions} + /> +
+
+ +
+ {showPartialBanner && ( +
+ {reachableNodeCount} of {onlineNodeCount} nodes reachable. Unreachable nodes are not shown. +
+ )} + {loading && groups.length === 0 ? ( +
Loading readiness...
+ ) : groups.length === 0 ? ( +
+
+ ) : ( + groups.map(group => ) + )} +
+
+ ); + } + return (
; +export default function AutoUpdateReadinessView(props: AutoUpdateReadinessProps = {}) { + return ; } diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index c73d63de..39f3ecdc 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -44,18 +44,26 @@ import { toast } from '@/components/ui/toast-store'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { MobileTabBar } from './MobileTabBar'; import { MobileMoreMenu } from './MobileMoreMenu'; +import { Masthead, type Tone } from './mobile/mobile-ui'; 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 { BESPOKE_MOBILE_VIEWS } from './EditorLayout/mobile-treatments'; +import { CapabilityGate } from './CapabilityGate'; +import { HubOnlyGate } from './HubOnlyGate'; import type { SectionId } from './settings/types'; -// The Security page is heavy (charts, scan sheets) and already code-split on the -// desktop content path, so the bespoke phone screen reuses the same lazy chunk -// rather than pulling SecurityView into the main shell bundle. +// These bespoke phone screens reuse the desktop view's component (with a mobile +// branch), code-split exactly like the desktop content path so the heavy chunks +// stay out of the main shell bundle. const SecurityView = lazy(() => import('./SecurityView').then(m => ({ default: m.SecurityView }))); +const AutoUpdateReadinessView = lazy(() => import('./AutoUpdateReadinessView')); +const AppStoreView = lazy(() => import('./AppStoreView').then(m => ({ default: m.AppStoreView }))); +const AuditLogView = lazy(() => import('./AuditLogView').then(m => ({ default: m.AuditLogView }))); +const ResourcesView = lazy(() => import('./ResourcesView')); +const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView }))); export default function EditorLayout() { const { isAdmin, can } = useAuth(); @@ -742,6 +750,12 @@ export default function EditorLayout() { // 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); + // Shared lazy-chunk fallback for the code-split bespoke phone screens. + const lazyFallback = ( +
+ +
+ ); const renderMobileBespoke = () => { switch (activeView) { case 'dashboard': @@ -781,18 +795,101 @@ export default function EditorLayout() { /> ); + case 'auto-updates': + // Same gates as the desktop content path (ViewRouter): hub-only + + // the auto-updates capability, preserved on the phone surface. + return ( + + + + + + + + ); + case 'templates': + return ( + + { refreshStacks(); void stackActions.loadFile(sName); }} + headerActions={mobileMastheadActions} + /> + + ); + case 'audit-log': + return ( + + + + + + + + ); + case 'resources': + return ( + + + + ); + case 'global-observability': + // Hub-only, like the desktop content path (ViewRouter); no capability gate. + return ( + + + + + + ); default: return workspaceEl; } }; + // The mobile Stacks list leads with the status masthead (no TopBar): the + // node switcher is its kicker chip, the serif word summarizes stack + // health, and notifications + the more-menu sit in the right slot. + // up counts 'running' and down counts 'exited'; any other status (or the + // window before statuses load) is neither, so "All running" must require + // every stack to be up rather than just no stack being down. + const { all: stacksAll, up: stacksUp, down: stacksDown, updates: stacksUpdates } = filterCounts; + let stacksState = 'All running'; + let stacksTone: Tone = 'success'; + if (stacksAll === 0) { + stacksState = 'No stacks'; + } else if (stacksDown > 0) { + stacksState = `${stacksDown} down`; + stacksTone = 'destructive'; + } else if (stacksUpdates > 0) { + stacksState = `${stacksUpdates} update${stacksUpdates === 1 ? '' : 's'}`; + stacksTone = 'warning'; + } else if (stacksUp !== stacksAll) { + stacksState = `${stacksUp}/${stacksAll} up`; + stacksTone = 'brand'; + } + const stacksMasthead = ( + openSettings('nodes')} />} + state={stacksState} + stateTone={stacksTone} + live={stacksDown > 0} + meta={`${stacksAll} ${stacksAll === 1 ? 'stack' : 'stacks'} · ${stacksUp} up · ${stacksDown} down`} + right={mobileMastheadActions} + /> + ); + if (isMobile) { return (
{commandPaletteEl} - {mobileSurface !== 'detail' && !bespokeContent && topBarEl} + {mobileSurface === 'content' && !bespokeContent && topBarEl}
- {mobileSurface === 'list' && sidebarEl} + {mobileSurface === 'list' && ( + <> + {stacksMasthead} + {sidebarEl} + + )} {mobileSurface === 'content' && (bespokeContent ? renderMobileBespoke() : workspaceEl)} {mobileSurface === 'detail' && ( detailReady ? ( diff --git a/frontend/src/components/EditorLayout/mobile-treatments.test.ts b/frontend/src/components/EditorLayout/mobile-treatments.test.ts index 25d797a9..4075d9e6 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.test.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.test.ts @@ -32,7 +32,7 @@ describe('mobile treatments', () => { // components/mobile/, or a masthead-led mobile branch of the desktop view as // Security does). expect([...BESPOKE_MOBILE_VIEWS].sort()).toEqual( - ['dashboard', 'fleet', 'scheduled-ops', 'security', 'settings'], + ['audit-log', 'auto-updates', 'dashboard', 'fleet', 'global-observability', 'resources', 'scheduled-ops', 'security', 'settings', 'templates'], ); }); }); diff --git a/frontend/src/components/EditorLayout/mobile-treatments.ts b/frontend/src/components/EditorLayout/mobile-treatments.ts index 960a9ed7..4846e668 100644 --- a/frontend/src/components/EditorLayout/mobile-treatments.ts +++ b/frontend/src/components/EditorLayout/mobile-treatments.ts @@ -21,12 +21,12 @@ export const MOBILE_TREATMENTS: Record = { 'scheduled-ops': 'bespoke', settings: 'bespoke', editor: 'detail', - resources: 'responsive', + resources: 'bespoke', security: 'bespoke', - templates: 'responsive', - 'global-observability': 'responsive', - 'auto-updates': 'responsive', - 'audit-log': 'responsive', + templates: 'bespoke', + 'global-observability': 'bespoke', + 'auto-updates': 'bespoke', + 'audit-log': 'bespoke', 'host-console': 'desktop-only', }; diff --git a/frontend/src/components/GlobalCommandPalette.tsx b/frontend/src/components/GlobalCommandPalette.tsx index 4b8c6cbf..62eb7a2e 100644 --- a/frontend/src/components/GlobalCommandPalette.tsx +++ b/frontend/src/components/GlobalCommandPalette.tsx @@ -67,7 +67,8 @@ export function GlobalCommandPaletteProvider({ children }: { children: ReactNode return {children}; } -function usePaletteState(): PaletteState { +// eslint-disable-next-line react-refresh/only-export-components +export function usePaletteState(): PaletteState { const ctx = useContext(PaletteContext); if (!ctx) throw new Error('usePaletteState must be used within GlobalCommandPaletteProvider'); return ctx; diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index 1fa7d415..eae65d7d 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -2,14 +2,16 @@ import { useEffect, useState, useMemo, useRef, useCallback, useLayoutEffect, mem import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { ScrollArea } from '@/components/ui/scroll-area'; import { PageMasthead } from '@/components/ui/PageMasthead'; import { SignalRail, type SignalTile } from '@/components/ui/SignalRail'; import { SegmentedControl, type SegmentedControlOption } from '@/components/ui/segmented-control'; -import { Download, Trash2, Search, Filter, AlertCircle, Pause, Play } from 'lucide-react'; +import { Download, Trash2, Search, Filter, AlertCircle, Pause, Play, SlidersHorizontal, X } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; +import { useIsMobile } from '@/hooks/use-is-mobile'; +import { Masthead } from '@/components/mobile/mobile-ui'; import { cn } from '@/lib/utils'; const MAX_LOG_ENTRIES = 2000; @@ -78,7 +80,15 @@ const LEVEL_OPTIONS: SegmentedControlOption[] = [ { value: 'ERROR', label: 'Error' }, ]; -export function GlobalObservabilityView() { +interface GlobalObservabilityViewProps { + /** Notifications + more-menu cluster for the mobile masthead, rehomed from the dropped TopBar. */ + headerActions?: ReactNode; +} + +export function GlobalObservabilityView({ headerActions }: GlobalObservabilityViewProps = {}) { + const isMobile = useIsMobile(); + const [searchOpen, setSearchOpen] = useState(false); + const [fabOpen, setFabOpen] = useState(false); const { activeNode } = useNodes(); const [logs, setLogs] = useState([]); const [allStacks, setAllStacks] = useState([]); @@ -360,16 +370,96 @@ export function GlobalObservabilityView() { return (
- - - + {isMobile ? ( + a + b, 0)}/min · ${counts.errors} err · ${counts.containers} containers`} + right={headerActions} + /> + ) : ( + <> + + + + )} + {isMobile ? ( +
+ {searchOpen ? ( +
+ + setSearchQuery(e.target.value)} + onBlur={() => { if (!searchQuery) setSearchOpen(false); }} + className="h-9 w-full bg-transparent pl-8 text-sm focus-visible:ring-brand/50" + /> +
+ ) : ( + + )} + {!searchOpen && ( + <> + + + + + + {allStacks.map(stack => ( + handleStackToggle(stack)}> + {stack} + + ))} + {allStacks.length === 0 && ( +
No stacks found
+ )} +
+
+ + + + + + Stream + {STREAM_OPTIONS.map(o => ( + setStreamFilter(o.value)}> + {o.label} + + ))} + + Level + {LEVEL_OPTIONS.map(o => ( + setLevelFilter(o.value)}> + {o.label} + + ))} + + + + )} +
+ ) : (
@@ -419,6 +509,7 @@ export function GlobalObservabilityView() { ariaLabel="Level filter" />
+ )} {fetchError && (
@@ -456,6 +547,44 @@ export function GlobalObservabilityView() {
+ {isMobile ? ( +
+ {isPaused && pendingCount > 0 && ( + + )} + {fabOpen ? ( +
+ + + + +
+ ) : ( + + )} +
+ ) : (
{isPaused && pendingCount > 0 && (
+ )}
); } diff --git a/frontend/src/components/MobileMoreMenu.test.tsx b/frontend/src/components/MobileMoreMenu.test.tsx index 7879a431..06464ffb 100644 --- a/frontend/src/components/MobileMoreMenu.test.tsx +++ b/frontend/src/components/MobileMoreMenu.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { Home, Radar, Boxes, ShieldCheck } from 'lucide-react'; import { MobileMoreMenu } from './MobileMoreMenu'; +import { GlobalCommandPaletteProvider, usePaletteState } from './GlobalCommandPalette'; import type { NavItem } from './EditorLayout/hooks/useViewNavigationState'; // Includes a bottom-tab primary (dashboard) and secondary destinations; the @@ -20,7 +21,13 @@ function open(over: Partial> = {}) { onNavigate: vi.fn(), ...over, }; - render(); + // The Search item reaches the command palette via usePaletteState, so the + // menu must render inside the palette provider. + render( + + + , + ); // The destination list lives inside a Sheet that is closed until the trigger // is clicked, so open it before querying the nav buttons. fireEvent.click(screen.getByRole('button', { name: 'More destinations' })); @@ -46,4 +53,23 @@ describe('MobileMoreMenu', () => { expect(screen.getByRole('button', { name: 'Security' }).className).toContain('bg-glass-highlight'); expect(screen.getByRole('button', { name: 'Resources' }).className).not.toContain('font-medium'); }); + + it('opens the command palette from the Search item', () => { + // The Stacks list drops the TopBar's search, so the menu's Search item is + // the way back to the palette: clicking it flips the shared palette state. + function PaletteProbe() { + const { open: paletteOpen } = usePaletteState(); + return {String(paletteOpen)}; + } + render( + + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'More destinations' })); + expect(screen.getByTestId('palette-open').textContent).toBe('false'); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); + expect(screen.getByTestId('palette-open').textContent).toBe('true'); + }); }); diff --git a/frontend/src/components/MobileMoreMenu.tsx b/frontend/src/components/MobileMoreMenu.tsx index 2da98aa2..cdc8d707 100644 --- a/frontend/src/components/MobileMoreMenu.tsx +++ b/frontend/src/components/MobileMoreMenu.tsx @@ -1,8 +1,9 @@ import { useState, type ReactNode } from 'react'; -import { Menu } from 'lucide-react'; +import { Menu, Search } from 'lucide-react'; import { Button } from './ui/button'; import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; import { cn } from '@/lib/utils'; +import { usePaletteState } from './GlobalCommandPalette'; import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState'; interface MobileMoreMenuProps { @@ -23,6 +24,9 @@ interface MobileMoreMenuProps { */ export function MobileMoreMenu({ navItems, activeView, onNavigate, footer }: MobileMoreMenuProps) { const [open, setOpen] = useState(false); + // The Stacks list drops the TopBar (which hosted the global search), so the + // command palette is reachable here instead. + const { setOpen: setPaletteOpen } = usePaletteState(); return ( @@ -41,6 +45,14 @@ export function MobileMoreMenu({ navItems, activeView, onNavigate, footer }: Mob

Navigate

); + const compactTrigger = ( + + + {activeNode?.name ?? (isLoading ? '…' : 'No node')} + + {hasMultiple ? : null} + + ); + const trigger = compact ? compactTrigger : triggerContent; + if (!hasMultiple) { - return triggerContent; + return trigger; } return ( - ('images'); const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const { isPaid } = useLicense(); @@ -715,9 +724,8 @@ export default function ResourcesView() { } }; - return ( -
- + const mainContent = ( + <> {/* Reclaim hero */} {heroVisible && usage && ( setResourceTab(v as typeof resourceTab)} className="flex-1 flex flex-col w-full rounded-lg border bg-card shadow-card-bevel overflow-hidden min-h-[400px] animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-150" > + {isMobile ? ( + + ) : (
@@ -853,6 +875,7 @@ export default function ResourcesView() { )}
+ )} @@ -1264,7 +1287,11 @@ export default function ResourcesView() { + + ); + const overlays = ( + <> {/* ── Dialogs ── */} {/* Prune Confirm */} @@ -1474,6 +1501,32 @@ export default function ResourcesView() { canCompare canManageSuppressions={isAdmin} /> + + ); + + if (isMobile) { + return ( +
+ 0 ? 'Reclaimable' : 'Tidy'} + stateTone={totalReclaimableBytes > 0 ? 'warning' : 'success'} + live={false} + meta={`${images.length} images · ${volumes.length} volumes · ${networks.length} networks`} + right={headerActions} + /> +
+ {mainContent} +
+ {overlays} +
+ ); + } + + return ( +
+ {mainContent} + {overlays}
); } diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 53bcd431..e32b2d5d 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen, ChevronLeft, ChevronRight } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react'; import { Button } from './ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; +import { ScrollableTabRow } from './ui/ScrollableTabRow'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { type AnatomyMarkdownInput } from '@/lib/anatomyMarkdown'; @@ -115,36 +116,6 @@ export default function StackAnatomyPanel({ return () => { cancelled = true; }; }, [stackName, activeNode?.id, doctorEnabled]); - // The tab row scrolls horizontally when its tabs overflow the panel width. - // Clickable arrows appear only while there is more to scroll in that direction - // (a wide panel that fits every tab looks unchanged), and a vertical mouse - // wheel over the row is translated into horizontal scroll. - const tabScrollRef = useRef(null); - const [tabEdges, setTabEdges] = useState({ left: false, right: false }); - const measureTabEdges = useCallback((el: HTMLElement) => { - setTabEdges({ left: el.scrollLeft > 1, right: Math.ceil(el.scrollLeft + el.clientWidth) < el.scrollWidth }); - }, []); - const scrollTabs = useCallback((direction: -1 | 1) => { - const el = tabScrollRef.current; - if (el) el.scrollBy({ left: direction * Math.max(96, el.clientWidth * 0.7), behavior: 'smooth' }); - }, []); - useEffect(() => { - const el = tabScrollRef.current; - if (!el) return; - measureTabEdges(el); - // Non-passive so preventDefault works: turn a vertical wheel into horizontal - // scroll only when the row overflows (trackpads already scroll horizontally). - const onWheel = (e: WheelEvent) => { - if (el.scrollWidth <= el.clientWidth || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; - el.scrollLeft += e.deltaY; - e.preventDefault(); - }; - el.addEventListener('wheel', onWheel, { passive: false }); - const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measureTabEdges(el)) : null; - ro?.observe(el); - return () => { el.removeEventListener('wheel', onWheel); ro?.disconnect(); }; - }, [measureTabEdges, doctorEnabled, preflightSeverity]); - useEffect(() => { let cancelled = false; const run = async () => { @@ -310,59 +281,30 @@ export default function StackAnatomyPanel({
-
-
measureTabEdges(e.currentTarget)} - className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" - > - - Anatomy - Activity - Dossier - Drift - {networkingEnabled && ( - Networking - )} - {doctorEnabled && ( - - - Doctor - {(preflightSeverity === 'blocker' || preflightSeverity === 'high') && ( - - )} - - - )} - -
- {/* Clickable arrows over a fade: shown only when the row overflows that edge. */} - {tabEdges.left && ( - - )} - {tabEdges.right && ( - - )} -
+ + + Anatomy + Activity + Dossier + Drift + {networkingEnabled && ( + Networking + )} + {doctorEnabled && ( + + + Doctor + {(preflightSeverity === 'blocker' || preflightSeverity === 'high') && ( + + )} + + + )} + +
{onOpenFiles && ( + ); + })} +
+ + ); +} + +export interface MobileChip { + value: T; + label: string; + /** Optional trailing count badge (dimmed). */ + count?: number; +} + +// Horizontal filter-chip scroller; active chip is cyan-filled. Used by the +// Security Images severity filter. Tap targets are >=44px. +export function MobileChipRow({ chips, active, onSelect, className }: { + chips: MobileChip[]; + active: T; + onSelect: (value: T) => void; + className?: string; +}) { + return ( +
+ {chips.map((chip) => { + const on = chip.value === active; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/security/SecurityMobile.tsx b/frontend/src/components/security/SecurityMobile.tsx index 5811b5b2..4e926642 100644 --- a/frontend/src/components/security/SecurityMobile.tsx +++ b/frontend/src/components/security/SecurityMobile.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from 'react'; import { ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { Kicker } from '@/components/mobile/mobile-ui'; +import { Kicker, MobileSubTabs, MobileChipRow } from '@/components/mobile/mobile-ui'; import { getSeverityKey, SEVERITY_DOT_CLASSES, type ImageFilterValue } from '@/lib/severityStyles'; import { formatTimeAgo } from '@/lib/relativeTime'; import type { SecurityTab } from '@/lib/events'; @@ -16,44 +16,14 @@ export interface SecurityMobileTab { label: string; } -/** Horizontal mono tab scroller with a cyan underline on the active tab and an - * edge mask-fade so the overflow reads as scrollable. Keeps all tabs reachable - * by scroll, matching the desktop tab IA. */ +/** The Security tab strip is the shared mono sub-tab scroller; every section + * stays reachable by horizontal scroll, matching the desktop IA. */ export function SecurityMobileTabs({ tabs, active, onSelect }: { tabs: SecurityMobileTab[]; active: SecurityTab; onSelect: (tab: SecurityTab) => void; }) { - return ( -
- {tabs.map((tab) => { - const on = tab.value === active; - return ( - - ); - })} -
- ); + return ; } function StripCell({ kicker, value, valueClass, last }: { @@ -185,32 +155,11 @@ export interface ImageFilterChip { label: string; } -/** Horizontal severity filter chips for the mobile Images list. */ +/** The Images severity filter is the shared mobile chip row. */ export function ImageFilterChips({ chips, active, onSelect }: { chips: ImageFilterChip[]; active: ImageFilterValue; onSelect: (value: ImageFilterValue) => void; }) { - return ( -
- {chips.map((chip) => { - const on = chip.value === active; - return ( - - ); - })} -
- ); + return ; } diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index 59d9b5d0..31ebcbf3 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -60,12 +60,13 @@ export function StackSidebar(props: StackSidebarProps) { return (
- {/* The TopBar provides the global chrome on mobile, so the in-sidebar - brand row is redundant there and hidden to save vertical space. */} + {/* On mobile the status masthead leads (it carries the node switcher as + its kicker chip), so the in-sidebar brand and node rows are redundant + there and hidden to save vertical space. */}
-
{nodeSwitcherSlot}
+
{nodeSwitcherSlot}
{canCreate && createStackSlot !== null && ( = { + card: { + left: 'from-card via-card/90 to-transparent', + right: 'from-card via-card/90 to-transparent', + }, + background: { + left: 'from-background via-background/90 to-transparent', + right: 'from-background via-background/90 to-transparent', + }, +}; + +export function ScrollableTabRow({ children, surface = 'card', className, wrapperClassName }: ScrollableTabRowProps) { + const scrollRef = useRef(null); + const [edges, setEdges] = useState({ left: false, right: false }); + + const measure = useCallback((el: HTMLElement) => { + const left = el.scrollLeft > 1; + const right = Math.ceil(el.scrollLeft + el.clientWidth) < el.scrollWidth; + // Bail when unchanged so the per-render measure effect cannot loop (a fresh + // object every render would re-trigger the effect forever). + setEdges(prev => (prev.left === left && prev.right === right ? prev : { left, right })); + }, []); + + const scrollBy = useCallback((direction: -1 | 1) => { + const el = scrollRef.current; + if (el) el.scrollBy({ left: direction * Math.max(96, el.clientWidth * 0.7), behavior: 'smooth' }); + }, []); + + // Re-measure after every render so a changed tab set (or width) updates the + // arrows; cheap and avoids threading the row's contents through a dep array. + useEffect(() => { + const el = scrollRef.current; + if (el) measure(el); + }); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + // Non-passive so preventDefault works: turn a vertical wheel into horizontal + // scroll only when the row overflows (trackpads already scroll horizontally). + const onWheel = (e: WheelEvent) => { + if (el.scrollWidth <= el.clientWidth || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + el.scrollLeft += e.deltaY; + e.preventDefault(); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measure(el)) : null; + ro?.observe(el); + return () => { el.removeEventListener('wheel', onWheel); ro?.disconnect(); }; + }, [measure]); + + const fade = FADE[surface]; + return ( +
+
measure(e.currentTarget)} + className={cn('overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', className)} + > + {children} +
+ {edges.left && ( + + )} + {edges.right && ( + + )} +
+ ); +}