@@ -568,7 +572,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogs
type="button"
onClick={() => setLogsMode('structured')}
className={cn(
- 'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
+ 'rounded px-2 py-0.5 font-mono text-xs uppercase tracking-wide transition-colors',
logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
)}
>
@@ -578,7 +582,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogs
type="button"
onClick={() => setLogsMode('raw')}
className={cn(
- 'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
+ 'rounded px-2 py-0.5 font-mono text-xs uppercase tracking-wide transition-colors',
logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
)}
>
@@ -588,7 +592,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogs
{logsMode === 'structured' ? (
-
+
) : (
diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx
index 5906180f..bfeeb390 100644
--- a/frontend/src/components/FleetSnapshots.tsx
+++ b/frontend/src/components/FleetSnapshots.tsx
@@ -590,22 +590,34 @@ export default function FleetSnapshots() {
.find(s => s.nodeId === node.nodeId && s.stackName === stack.stackName)?.dossier;
return (
-
toggleStack(stackKey)}
- className="flex items-center gap-2 w-full px-3 py-2 text-left rounded-md hover:bg-muted/50 transition-colors"
- >
- {stackExpanded
- ?
- :
- }
-
-
- {stack.stackName}
-
-
- {stack.files.length} file{stack.files.length !== 1 ? 's' : ''}
-
-
+
+ toggleStack(stackKey)}
+ className="flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-left"
+ >
+ {stackExpanded
+ ?
+ :
+ }
+
+
+ {stack.stackName}
+
+
+ {stack.files.length} file{stack.files.length !== 1 ? 's' : ''}
+
+
+ {isAdmin && (
+
+ )}
+
{/* Files */}
{stackExpanded && (
@@ -650,20 +662,6 @@ export default function FleetSnapshots() {
{/* Preserved dossier notes (read-only) */}
{dossier &&
}
-
- {/* Restore (admin only), right-aligned to match the file action row */}
- {isAdmin && (
-
-
-
- )}
)}
diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx
index f82fec72..c15855f4 100644
--- a/frontend/src/components/FleetView.tsx
+++ b/frontend/src/components/FleetView.tsx
@@ -31,9 +31,12 @@ import { SecretsTab } from './fleet/secrets/SecretsTab';
import { DependencyMapTab } from './fleet/DependencyMapTab';
import { useNodeActions } from './nodes/useNodeActions';
import type { FleetTab } from '@/lib/events';
+import type { SectionId } from '@/components/settings/types';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
+ /** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
+ onOpenSettingsSection?: (section: SectionId) => void;
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
onFleetUpdatesIntentConsumed?: () => void;
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
@@ -41,7 +44,7 @@ interface FleetViewProps {
onFleetTabConsumed?: () => void;
}
-export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
+export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
@@ -81,7 +84,7 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
const { mastheadStats, lastSyncAt, loading, refreshing } = overview;
- const { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({
+ const { openEdit, openDelete, NodeActionModals } = useNodeActions({
onNodeChange: () => { void overview.fetchOverview(true); },
});
@@ -103,7 +106,9 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
setActiveTab(v as FleetTab)}>
-
+ {/* Flatten the list's own pill band so the tabs sit directly in
+ the single full-width band, not a nested second band. */}
+
Overview
@@ -216,7 +221,7 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
onCordonChange={() => { void overview.fetchOverview(true); }}
onEditNode={isAdmin ? openEdit : undefined}
onDeleteNode={isAdmin ? openDelete : undefined}
- onAddNode={isAdmin ? openCreate : undefined}
+ onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
onCheckUpdates={updateStatus.checkUpdates}
checkingUpdates={updateStatus.checkingUpdates}
topologyMode={topology.prefs.mode}
diff --git a/frontend/src/components/FleetView/OverviewToolbar.tsx b/frontend/src/components/FleetView/OverviewToolbar.tsx
index aac1ec14..2df5cae2 100644
--- a/frontend/src/components/FleetView/OverviewToolbar.tsx
+++ b/frontend/src/components/FleetView/OverviewToolbar.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Search, ArrowUpDown, AlertTriangle, Play, Square,
- LayoutGrid, Network, SlidersHorizontal, Plus, RefreshCcwDot,
+ LayoutGrid, Network, SlidersHorizontal, ChartNetwork, RefreshCcwDot,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -259,25 +259,27 @@ export function OverviewToolbar({
+ Node Update
)}
{onAddNode && (
-
+
)}
diff --git a/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx
index 7d92e3b5..a2b76a1b 100644
--- a/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx
+++ b/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx
@@ -82,32 +82,32 @@ describe('OverviewToolbar', () => {
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
});
- it('renders the Add node button and fires onAddNode when provided', () => {
+ it('renders the manage-nodes button and fires onAddNode when provided', () => {
const onAddNode = vi.fn();
render( );
- fireEvent.click(screen.getByRole('button', { name: 'Add node' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Manage nodes' }));
expect(onAddNode).toHaveBeenCalledTimes(1);
});
- it('omits the Add node button when onAddNode is not provided', () => {
+ it('omits the manage-nodes button when onAddNode is not provided', () => {
render( );
- expect(screen.queryByRole('button', { name: 'Add node' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Manage nodes' })).not.toBeInTheDocument();
});
it('renders the Check Updates button and fires onCheckUpdates when provided', () => {
const onCheckUpdates = vi.fn();
render( );
- fireEvent.click(screen.getByRole('button', { name: /Check for updates/ }));
+ fireEvent.click(screen.getByRole('button', { name: /Check for node updates/ }));
expect(onCheckUpdates).toHaveBeenCalledTimes(1);
});
it('omits the Check Updates button when onCheckUpdates is not provided', () => {
render( );
- expect(screen.queryByRole('button', { name: /Check for updates/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Check for node updates/ })).not.toBeInTheDocument();
});
it('disables the Check Updates button while a check is in flight', () => {
render( );
- expect(screen.getByRole('button', { name: /Check for updates/ })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Check for node updates/ })).toBeDisabled();
});
});
diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx
index de4fb61b..b2dd89a8 100644
--- a/frontend/src/components/SecurityView.tsx
+++ b/frontend/src/components/SecurityView.tsx
@@ -396,8 +396,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
onTabChange(v as SecurityTab)}>
-
-
+ {/* Standard full-width tab band (matches Fleet): the list's own pill band
+ is flattened so the tabs sit directly in this single band. */}
+
+
+
Overview
@@ -424,7 +427,8 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
Scanner setup
-
+
+
{tabPanels}
diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx
index da62f2e7..a5710991 100644
--- a/frontend/src/components/StackAnatomyPanel.tsx
+++ b/frontend/src/components/StackAnatomyPanel.tsx
@@ -64,7 +64,7 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
return (
{label}
-
{children}
+
{children}
);
}
@@ -359,18 +359,18 @@ export default function StackAnatomyPanel({
- Anatomy
- Activity
- Dossier
- Drift
+ Anatomy
+ Activity
+ Dossier
+ Drift
{envInventoryEnabled && (
- Environment
+ Environment
)}
{networkingEnabled && (
- Networking
+ Networking
)}
{doctorEnabled && (
-
+
Doctor
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && !doctorDismissed && (
@@ -383,7 +383,7 @@ export default function StackAnatomyPanel({
)}
{storageEnabled && (
- Storage
+ Storage
)}
@@ -417,7 +417,7 @@ export default function StackAnatomyPanel({
{!anatomy ? (
-
Unable to parse compose.yaml.
+
Unable to parse compose.yaml.
) : (
<>
@@ -426,7 +426,7 @@ export default function StackAnatomyPanel({
) : (
{anatomy.services.map(s => (
-
+
{s}
))}
@@ -437,7 +437,7 @@ export default function StackAnatomyPanel({
{Object.keys(anatomy.ports).length === 0 ? (
none
) : (
-
+
{Object.entries(anatomy.ports).flatMap(([svc, rows]) =>
rows.map((r, i) => (
@@ -457,7 +457,7 @@ export default function StackAnatomyPanel({
{Object.keys(anatomy.volumes).length === 0 ? (
none
) : (
-
+
{Object.entries(anatomy.volumes).flatMap(([svc, rows]) =>
rows.map((r, i) => (
@@ -474,19 +474,19 @@ export default function StackAnatomyPanel({
)}
- {anatomy.restart ?? default }
+ {anatomy.restart ?? default }
{!firstEnvFile ? (
none
) : (
-
+
{firstEnvFile}
· {envVarCount} var{envVarCount === 1 ? '' : 's'}
{missingVars.length > 0 && (
-
+
{missingVars.map(v => (
{'${'}{v}{'}'} missing
))}
@@ -496,14 +496,14 @@ export default function StackAnatomyPanel({
)}
- {networkName} · bridge
+ {networkName} · bridge
{activeGitSource ? (
@@ -522,7 +522,7 @@ export default function StackAnatomyPanel({
-
+
Update available
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
@@ -533,7 +533,7 @@ export default function StackAnatomyPanel({
)}
-
+
{[
bumpLabel,
bannerLeadIn,
@@ -562,7 +562,7 @@ export default function StackAnatomyPanel({
)}
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && (
diff --git a/frontend/src/components/StructuredLogViewer.tsx b/frontend/src/components/StructuredLogViewer.tsx
index 233d92d1..c83603b2 100644
--- a/frontend/src/components/StructuredLogViewer.tsx
+++ b/frontend/src/components/StructuredLogViewer.tsx
@@ -1,12 +1,15 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from './ui/button';
-import { Download, RefreshCw } from 'lucide-react';
+import { Download, RefreshCw, Maximize2, Minimize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useLogChipColorMode } from '@/hooks/use-log-chip-color-mode';
import { hashLabel } from '@/lib/label-colors';
interface StructuredLogViewerProps {
stackName: string;
+ /** When set, renders an expand/collapse control next to the download button. */
+ expanded?: boolean;
+ onToggleExpand?: () => void;
}
type LogLevel = 'info' | 'warn' | 'err';
@@ -65,7 +68,7 @@ function formatTs(iso: string | null): string {
return `${hh}:${mm}:${ss}`;
}
-export default function StructuredLogViewer({ stackName }: StructuredLogViewerProps) {
+export default function StructuredLogViewer({ stackName, expanded, onToggleExpand }: StructuredLogViewerProps) {
const [rows, setRows] = useState
([]);
const [filter, setFilter] = useState('all');
const [following, setFollowing] = useState(true);
@@ -281,6 +284,21 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
))}
+ {onToggleExpand && (
+
+ {expanded
+ ?
+ : }
+
+ )}
@@ -289,7 +307,7 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
{filtered.length === 0 ? (
Waiting for log output…
diff --git a/frontend/src/components/animate-ui/primitives/radix/dialog.tsx b/frontend/src/components/animate-ui/primitives/radix/dialog.tsx
index 3730aead..b8e12b7c 100644
--- a/frontend/src/components/animate-ui/primitives/radix/dialog.tsx
+++ b/frontend/src/components/animate-ui/primitives/radix/dialog.tsx
@@ -7,6 +7,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { useControlledState } from '@/hooks/use-controlled-state';
import { getStrictContext } from '@/lib/get-strict-context';
+import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition';
type DialogContextType = {
isOpen: boolean;
@@ -73,6 +74,7 @@ function DialogOverlay({
transition = { duration: 0.2, ease: 'easeInOut' },
...props
}: DialogOverlayProps) {
+ const resolvedTransition = useReducedTransition(transition);
return (
@@ -111,6 +113,7 @@ function DialogContent({
from === 'bottom' || from === 'left' ? '20deg' : '-20deg';
const isVertical = from === 'top' || from === 'bottom';
const rotateAxis = isVertical ? 'rotateX' : 'rotateY';
+ const resolvedTransition = useReducedTransition(transition);
return (
diff --git a/frontend/src/components/animate-ui/primitives/radix/dropdown-menu.tsx b/frontend/src/components/animate-ui/primitives/radix/dropdown-menu.tsx
index b828b906..22edb6f1 100644
--- a/frontend/src/components/animate-ui/primitives/radix/dropdown-menu.tsx
+++ b/frontend/src/components/animate-ui/primitives/radix/dropdown-menu.tsx
@@ -13,6 +13,7 @@ import {
} from '@/components/animate-ui/primitives/effects/highlight';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
+import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition';
import { useDataState } from '@/hooks/use-data-state';
type DropdownMenuContextType = {
@@ -198,6 +199,7 @@ function DropdownMenuSubContent({
...props
}: DropdownMenuSubContentProps) {
const { isOpen } = useDropdownMenuSub();
+ const resolvedTransition = useReducedTransition(transition);
return (
@@ -226,7 +228,7 @@ function DropdownMenuSubContent({
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
- transition={transition}
+ transition={resolvedTransition}
style={{ willChange: 'opacity, transform', ...style }}
{...props}
/>
@@ -295,6 +297,7 @@ function DropdownMenuContent({
...props
}: DropdownMenuContentProps) {
const { isOpen } = useDropdownMenu();
+ const resolvedTransition = useReducedTransition(transition);
return (
@@ -325,7 +328,7 @@ function DropdownMenuContent({
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
- transition={transition}
+ transition={resolvedTransition}
style={{ willChange: 'opacity, transform', ...style }}
{...props}
/>
diff --git a/frontend/src/components/animate-ui/primitives/radix/popover.tsx b/frontend/src/components/animate-ui/primitives/radix/popover.tsx
index c022ac4b..da7c8b52 100644
--- a/frontend/src/components/animate-ui/primitives/radix/popover.tsx
+++ b/frontend/src/components/animate-ui/primitives/radix/popover.tsx
@@ -7,6 +7,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
+import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition';
type PopoverContextType = {
isOpen: boolean;
@@ -91,6 +92,7 @@ function PopoverContent({
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: PopoverContentProps) {
+ const resolvedTransition = useReducedTransition(transition);
return (
diff --git a/frontend/src/components/animate-ui/primitives/radix/sheet.tsx b/frontend/src/components/animate-ui/primitives/radix/sheet.tsx
index 59f7a0ae..11dfdaad 100644
--- a/frontend/src/components/animate-ui/primitives/radix/sheet.tsx
+++ b/frontend/src/components/animate-ui/primitives/radix/sheet.tsx
@@ -7,6 +7,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
+import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition';
type SheetContextType = {
isOpen: boolean;
@@ -72,6 +73,7 @@ function SheetOverlay({
transition = { duration: 0.2, ease: 'easeInOut' },
...props
}: SheetOverlayProps) {
+ const resolvedTransition = useReducedTransition(transition);
return (
@@ -102,6 +104,7 @@ function SheetContent({
...props
}: SheetContentProps) {
const axis = side === 'left' || side === 'right' ? 'x' : 'y';
+ const resolvedTransition = useReducedTransition(transition);
const offscreen: Record = {
right: { x: '100%', opacity: 0 },
@@ -131,7 +134,7 @@ function SheetContent({
...positionStyle[side],
...style,
}}
- transition={transition}
+ transition={resolvedTransition}
>
{children}
diff --git a/frontend/src/components/animate-ui/primitives/use-reduced-transition.ts b/frontend/src/components/animate-ui/primitives/use-reduced-transition.ts
new file mode 100644
index 00000000..b6532471
--- /dev/null
+++ b/frontend/src/components/animate-ui/primitives/use-reduced-transition.ts
@@ -0,0 +1,15 @@
+import { useReducedMotion } from '@/hooks/use-theme';
+
+const INSTANT = { duration: 0 } as const;
+
+/**
+ * Returns an instant (zero-duration) transition when the "Reduced motion"
+ * appearance setting is on, otherwise the supplied transition. This reads our
+ * app setting (not framer-motion's useReducedMotion, which only reflects the OS
+ * prefers-reduced-motion). `MotionConfig`'s reducedMotion only neutralizes
+ * transform/layout animations, so overlay primitives that fade or blur
+ * (opacity/filter) still animate; this collapses those too.
+ */
+export function useReducedTransition(transition: T): T | typeof INSTANT {
+ return useReducedMotion() ? INSTANT : transition;
+}
diff --git a/frontend/src/components/blueprints/BlueprintEmptyState.tsx b/frontend/src/components/blueprints/BlueprintEmptyState.tsx
index 3ec41398..25c693e2 100644
--- a/frontend/src/components/blueprints/BlueprintEmptyState.tsx
+++ b/frontend/src/components/blueprints/BlueprintEmptyState.tsx
@@ -16,7 +16,7 @@ export function BlueprintEmptyState({ onCreate, canCreate }: BlueprintEmptyState
Deployments · Blueprints
-
+
Declare once. Distribute everywhere.
@@ -47,7 +47,7 @@ function Step({ kicker, title, copy }: { kicker: string; title: string; copy: st
return (
{kicker}
- {title}
+ {title}
{copy}
);
diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx
index 0ebb2dc6..a2a77a61 100644
--- a/frontend/src/components/ui/tabs.tsx
+++ b/frontend/src/components/ui/tabs.tsx
@@ -32,7 +32,7 @@ const TabsTrigger = React.forwardRef<