;
+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"
+ />
+
+ ) : (
+
setSearchOpen(true)}>
+
+
+ )}
+ {!searchOpen && (
+ <>
+
+
+
+
+
+ Stacks · {selectedStacks.length === 0 ? 'All' : selectedStacks.length}
+
+
+
+
+ {allStacks.map(stack => (
+ handleStackToggle(stack)}>
+ {stack}
+
+ ))}
+ {allStacks.length === 0 && (
+ No stacks found
+ )}
+
+
+
+
+
+
+ Filters
+
+
+
+ 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 && (
+
+ {pendingCount} new · resume
+
+ )}
+ {fabOpen ? (
+
+
{ setIsPaused(p => !p); setFabOpen(false); }} aria-label={isPaused ? 'Resume stream' : 'Pause stream'}>
+ {isPaused ? : }
+
+
{ handleClearLogs(); setFabOpen(false); }} aria-label="Clear log buffer">
+
+
+
{ handleDownload(); setFabOpen(false); }} aria-label="Download logs">
+
+
+
setFabOpen(false)} aria-label="Close actions">
+
+
+
+ ) : (
+
setFabOpen(true)}
+ aria-label="Log actions"
+ className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-glass-border bg-popover/95 text-stat-value shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]"
+ >
+ {isPaused ? : }
+
+ )}
+
+ ) : (
{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
+ { setPaletteOpen(true); setOpen(false); }}
+ className="flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm text-muted-foreground transition-colors hover:bg-glass-highlight hover:text-foreground"
+ >
+
+ Search
+
{navItems.map(({ value, label, icon: Icon }) => (
void;
+ /** One-line chip for the mobile Stacks masthead kicker (node name + chevron),
+ * instead of the full bordered card. */
+ compact?: boolean;
}
function dotClass(status: Node['status']): string {
@@ -29,7 +32,7 @@ function typeLabel(type: Node['type'], mode: NodeMode | undefined): string {
return 'Remote';
}
-export function NodeSwitcher({ onManageNodes }: NodeSwitcherProps) {
+export function NodeSwitcher({ onManageNodes, compact = false }: NodeSwitcherProps) {
const { nodes, activeNode, setActiveNode, nodeMeta, isLoading } = useNodes();
const [open, setOpen] = useState(false);
@@ -79,15 +82,25 @@ export function NodeSwitcher({ onManageNodes }: NodeSwitcherProps) {
);
+ const compactTrigger = (
+
+
+ {activeNode?.name ?? (isLoading ? '…' : 'No node')}
+
+ {hasMultiple ? : null}
+
+ );
+ const trigger = compact ? compactTrigger : triggerContent;
+
if (!hasMultiple) {
- return triggerContent;
+ return trigger;
}
return (
-
- {triggerContent}
+
+ {trigger}
('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 && (
-
scrollTabs(-1)}
- className="absolute inset-y-0 left-0 flex w-7 items-center justify-start bg-gradient-to-r from-card via-card/90 to-transparent text-stat-subtitle hover:text-brand transition-colors"
- >
-
-
- )}
- {tabEdges.right && (
-
scrollTabs(1)}
- className="absolute inset-y-0 right-0 flex w-7 items-center justify-end bg-gradient-to-l from-card via-card/90 to-transparent text-stat-subtitle hover:text-brand transition-colors"
- >
-
-
- )}
-
+
+
+ Anatomy
+ Activity
+ Dossier
+ Drift
+ {networkingEnabled && (
+ Networking
+ )}
+ {doctorEnabled && (
+
+
+ Doctor
+ {(preflightSeverity === 'blocker' || preflightSeverity === 'high') && (
+
+ )}
+
+
+ )}
+
+
{onOpenFiles && (
= {}): StackCard {
+ return {
+ stack: 'nextcloud',
+ nodeId: 1,
+ previewLoaded: true,
+ applying: false,
+ autoUpdateEnabled: true,
+ scheduledTask: null,
+ preview: {
+ stack_name: 'nextcloud',
+ images: [],
+ summary: {
+ has_update: true,
+ primary_image: 'nextcloud',
+ current_tag: '27.1.4',
+ next_tag: '27.1.5',
+ semver_bump: 'patch',
+ update_kind: 'tag',
+ blocked: false,
+ blocked_reason: null,
+ },
+ rollback_target: null,
+ changelog: 'Fixes. Security patch.',
+ },
+ ...over,
+ };
+}
+
+const apply = () => screen.getByRole('button', { name: /Apply now/i });
+
+it('enables Apply when a covering schedule exists and the update is not blocked', () => {
+ render( );
+ expect(apply()).toBeEnabled();
+});
+
+it('disables Apply when the update is blocked (major bump)', () => {
+ render(
+ ,
+ );
+ expect(apply()).toBeDisabled();
+});
+
+it('disables Apply when auto-update is off for the stack', () => {
+ render( );
+ expect(apply()).toBeDisabled();
+});
diff --git a/frontend/src/components/mobile/mobile-ui.test.tsx b/frontend/src/components/mobile/mobile-ui.test.tsx
new file mode 100644
index 00000000..5ef4e7b4
--- /dev/null
+++ b/frontend/src/components/mobile/mobile-ui.test.tsx
@@ -0,0 +1,51 @@
+/**
+ * Shared mobile chrome: the chip row (the Security Images severity filter) and
+ * the sub-tab scroller (the Security sections, the Audit Stream/Table views).
+ * Both render a value/label[/count] list, mark the active item, and report
+ * selection.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MobileChipRow, MobileSubTabs } from './mobile-ui';
+
+describe('MobileChipRow', () => {
+ const chips = [
+ { value: 'All', label: 'All', count: 124 },
+ { value: 'Media', label: 'Media', count: 28 },
+ { value: 'Dev', label: 'Dev', count: 34 },
+ ];
+
+ it('renders every category chip with its count', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /Media/ })).toHaveTextContent('28');
+ expect(screen.getAllByRole('button')).toHaveLength(3);
+ });
+
+ it('filters to the chosen category on click', () => {
+ const onSelect = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /Dev/ }));
+ expect(onSelect).toHaveBeenCalledWith('Dev');
+ });
+
+ it('cyan-fills the active chip only', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /Media/ }).className).toContain('bg-brand');
+ expect(screen.getByRole('button', { name: /Dev/ }).className).not.toContain('bg-brand');
+ });
+});
+
+describe('MobileSubTabs', () => {
+ const tabs = [
+ { value: 'images', label: 'Images', count: 42 },
+ { value: 'volumes', label: 'Volumes', count: 18 },
+ ];
+
+ it('marks the active tab and reports selection', () => {
+ const onSelect = vi.fn();
+ render( );
+ expect(screen.getByRole('tab', { name: /Images/ })).toHaveAttribute('aria-selected', 'true');
+ fireEvent.click(screen.getByRole('tab', { name: /Volumes/ }));
+ expect(onSelect).toHaveBeenCalledWith('volumes');
+ });
+});
diff --git a/frontend/src/components/mobile/mobile-ui.tsx b/frontend/src/components/mobile/mobile-ui.tsx
index b1708772..b34c1bf5 100644
--- a/frontend/src/components/mobile/mobile-ui.tsx
+++ b/frontend/src/components/mobile/mobile-ui.tsx
@@ -4,6 +4,7 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Sparkline } from '@/components/ui/sparkline';
+import { ScrollableTabRow } from '@/components/ui/ScrollableTabRow';
export type Tone = 'success' | 'warning' | 'destructive' | 'brand';
@@ -148,32 +149,49 @@ export function BackChip({ label, onClick }: { label: string; onClick?: () => vo
);
}
-// 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;
+// Status masthead: the ONE bespoke-mobile header. 3px cyan left rail, kicker,
+// serif-italic state word + tone-colored pulsing dot, mono meta line, and a
+// right slot for the rehomed global chrome (notifications + more-menu).
+//
+// This is the standard set by Home, Fleet, and Security, and EVERY bespoke
+// mobile page uses it (the Stacks list, Resources, App Store, Updates, Audit,
+// Logs; the Schedule page is the one exception). DO NOT introduce a second
+// header style (a title-led header, a back chip / "< Stacks" link, etc.) for
+// these pages - navigation back is via the bottom tab bar and the more-menu.
+// Derive a status word per page (e.g. "Up to date" / "4 pending", "Streaming",
+// "Healthy"), not a bare page title.
+//
+// The kicker has exactly one source: `kicker` (wrapped in the styled Kicker) or
+// `kickerSlot` (raw content, e.g. the Stacks node chip). The union forbids both
+// and neither.
+type MastheadProps = {
state: ReactNode;
stateTone?: Tone;
meta?: ReactNode;
right?: ReactNode;
live?: boolean;
stateClassName?: string;
-}) {
+} & (
+ | { kicker: ReactNode; kickerSlot?: never }
+ | { kickerSlot: ReactNode; kicker?: never }
+);
+
+export function Masthead({
+ kicker,
+ kickerSlot,
+ state,
+ stateTone = 'success',
+ meta,
+ right,
+ live = true,
+ stateClassName,
+}: MastheadProps) {
return (
-
{kicker}
+
{kickerSlot ?? {kicker} }
{state}
@@ -185,3 +203,84 @@ export function Masthead({
);
}
+
+export interface MobileSubTab
{
+ value: T;
+ label: string;
+ /** Optional trailing count badge (dimmed). */
+ count?: number;
+}
+
+// Horizontal mono tab scroller with a cyan underline on the active tab. When
+// the row overflows it gets the shared fade + arrow affordance (ScrollableTabRow).
+// Shared by the Security page and the Audit Log page. Tap targets are >=44px.
+export function MobileSubTabs({ tabs, active, onSelect, ariaLabel }: {
+ tabs: MobileSubTab[];
+ active: T;
+ onSelect: (value: T) => void;
+ ariaLabel: string;
+}) {
+ return (
+
+
+ {tabs.map((tab) => {
+ const on = tab.value === active;
+ return (
+ onSelect(tab.value)}
+ className={cn(
+ 'min-h-11 shrink-0 whitespace-nowrap px-3 py-[13px] font-mono text-[12px] tracking-[0.04em] transition-colors',
+ on ? 'text-brand shadow-[inset_0_-2px_0_0_var(--brand)]' : 'text-stat-subtitle',
+ )}
+ >
+ {tab.label}
+ {tab.count != null && {tab.count} }
+
+ );
+ })}
+
+
+ );
+}
+
+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 (
+ onSelect(chip.value)}
+ className={cn(
+ 'min-h-11 shrink-0 whitespace-nowrap rounded-[7px] border px-[11px] font-mono text-[11px] tracking-[0.04em] transition-colors',
+ on ? 'border-brand bg-brand text-brand-foreground' : 'border-card-border bg-card text-stat-subtitle',
+ )}
+ >
+ {chip.label}
+ {chip.count != null && {chip.count} }
+
+ );
+ })}
+
+ );
+}
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 (
- onSelect(tab.value)}
- className={cn(
- 'min-h-11 shrink-0 whitespace-nowrap px-3 py-[13px] font-mono text-[12px] tracking-[0.04em] transition-colors',
- on ? 'text-brand shadow-[inset_0_-2px_0_0_var(--brand)]' : 'text-stat-subtitle',
- )}
- >
- {tab.label}
-
- );
- })}
-
- );
+ 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 (
- onSelect(chip.value)}
- className={cn(
- 'min-h-11 shrink-0 whitespace-nowrap rounded-[7px] border px-[11px] font-mono text-[11px] tracking-[0.04em] transition-colors',
- on
- ? 'border-brand bg-brand text-brand-foreground'
- : 'border-card-border bg-card text-stat-subtitle',
- )}
- >
- {chip.label}
-
- );
- })}
-
- );
+ 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 && (
+
scrollBy(-1)}
+ className={cn('absolute inset-y-0 left-0 flex w-7 items-center justify-start bg-gradient-to-r text-stat-subtitle transition-colors hover:text-brand', fade.left)}
+ >
+
+
+ )}
+ {edges.right && (
+
scrollBy(1)}
+ className={cn('absolute inset-y-0 right-0 flex w-7 items-center justify-end bg-gradient-to-l text-stat-subtitle transition-colors hover:text-brand', fade.right)}
+ >
+
+
+ )}
+
+ );
+}