feat(fleet): tidy the Overview toolbar and shorten tab labels (#1361)

- Move the Add node button into the Overview toolbar beside the
  Grid/Topology toggle so it sits with the view it acts on instead of
  showing on every Fleet tab. It stays admin-only.
- Collapse the node search to an icon button that expands to the full
  input on click and collapses again on blur once the query is empty,
  reclaiming toolbar width. An active query keeps it open.
- Match the sort-direction toggle and the sort dropdown to the outlined
  dark fill of the Filters button for a consistent toolbar row.
- Swap the Check Updates icon to the refresh-with-dot glyph.
- Rename two tabs: Fleet Actions to Actions, Dependencies to Map
  (display labels only; internal keys unchanged).

Update the feature docs to the new tab labels.
This commit is contained in:
Anso
2026-06-12 02:10:21 -04:00
committed by GitHub
parent f253276303
commit bef51a979f
8 changed files with 108 additions and 39 deletions
+5 -15
View File
@@ -1,5 +1,5 @@
import {
RefreshCw, Search, Camera, Plus, FileDown,
RefreshCw, RefreshCcwDot, Camera, FileDown,
Network, SlidersHorizontal,
Send, KeyRound, ArrowLeftRight, Wrench, Workflow,
} from 'lucide-react';
@@ -29,7 +29,6 @@ import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
import { SecretsTab } from './fleet/secrets/SecretsTab';
import { DependencyMapTab } from './fleet/DependencyMapTab';
import { useNodeActions } from './nodes/useNodeActions';
import { SettingsPrimaryButton } from './settings/SettingsActions';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
@@ -94,7 +93,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</TabsHighlightItem>
<TabsHighlightItem value="dependencies">
<TabsTrigger value="dependencies">
<Workflow className="w-4 h-4 mr-1.5" />Dependencies
<Workflow className="w-4 h-4 mr-1.5" />Map
</TabsTrigger>
</TabsHighlightItem>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
@@ -121,7 +120,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
)}
<TabsHighlightItem value="actions">
<TabsTrigger value="actions">
<Wrench className="w-4 h-4 mr-1.5" />Fleet Actions
<Wrench className="w-4 h-4 mr-1.5" />Actions
</TabsTrigger>
</TabsHighlightItem>
{isPaid && isAdmin && (
@@ -140,7 +139,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
onClick={updateStatus.checkUpdates}
className="gap-2"
>
<Search className="w-4 h-4" />
<RefreshCcwDot className="w-4 h-4" />
Check Updates
</Button>
<Button
@@ -165,16 +164,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{exporting ? 'Exporting…' : 'Export Dossier'}
</Button>
)}
{isAdmin && (
<SettingsPrimaryButton
size="sm"
onClick={openCreate}
className="gap-1"
>
<Plus className="w-4 h-4" />
Add node
</SettingsPrimaryButton>
)}
</div>
</div>
@@ -205,6 +194,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
onCordonChange={() => { void overview.fetchOverview(true); }}
onEditNode={isAdmin ? openEdit : undefined}
onDeleteNode={isAdmin ? openDelete : undefined}
onAddNode={isAdmin ? openCreate : undefined}
topologyMode={topology.prefs.mode}
onTopologyModeChange={topology.setMode}
topologyPositions={topology.prefs.positions}
@@ -39,6 +39,7 @@ interface OverviewTabProps {
onTopologyModeChange: (mode: LayoutMode) => void;
topologyPositions: SavedPositions;
onTopologyPositionsChange: (positions: SavedPositions) => void;
onAddNode?: () => void;
}
export function OverviewTab({
@@ -71,6 +72,7 @@ export function OverviewTab({
onTopologyModeChange,
topologyPositions,
onTopologyPositionsChange,
onAddNode,
}: OverviewTabProps) {
return (
<>
@@ -113,6 +115,7 @@ export function OverviewTab({
labelFilters={labelFilters}
onLabelFiltersChange={onLabelFiltersChange}
onClearFilters={onClearFilters}
onAddNode={onAddNode}
/>
{viewMode === 'topology' && processedNodes.length > 0 ? (
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Search, ArrowUpDown, AlertTriangle, Play, Square,
LayoutGrid, Network, SlidersHorizontal,
LayoutGrid, Network, SlidersHorizontal, Plus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -49,6 +49,7 @@ interface OverviewToolbarProps {
labelFilters: Set<string>;
onLabelFiltersChange: (filters: Set<string>) => void;
onClearFilters: () => void;
onAddNode?: () => void;
}
export function OverviewToolbar({
@@ -62,6 +63,7 @@ export function OverviewToolbar({
labelFilters,
onLabelFiltersChange,
onClearFilters,
onAddNode,
}: OverviewToolbarProps) {
const showGridControls = viewMode === 'grid';
const activeFilterCount =
@@ -75,29 +77,56 @@ export function OverviewToolbar({
[fleetPalette],
);
// Collapsed by default to a single icon button; expands to the full input on
// click and collapses again on blur once the query is cleared. An active
// query keeps it open so the filter stays visible and editable.
const [searchExpanded, setSearchExpanded] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchOpen = searchExpanded || searchQuery !== '';
useEffect(() => {
if (searchExpanded) searchInputRef.current?.focus();
}, [searchExpanded]);
return (
<div className="flex flex-wrap items-center gap-2 mb-4">
{showGridControls && (
<>
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search nodes or stacks..."
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
className="pl-9 h-9"
/>
</div>
{searchOpen ? (
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
placeholder="Search nodes or stacks..."
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
onBlur={() => { if (searchQuery === '') setSearchExpanded(false); }}
className="pl-9 h-9"
/>
</div>
) : (
<Button
variant="outline"
size="sm"
className="h-9 w-9 p-0 shrink-0"
onClick={() => setSearchExpanded(true)}
title="Search nodes or stacks"
aria-label="Search nodes or stacks"
>
<Search className="w-4 h-4" />
</Button>
)}
<div className="w-40">
<Combobox
options={SORT_OPTIONS}
value={prefs.sortBy}
onValueChange={(v) => onPrefsChange({ sortBy: v as SortField })}
placeholder="Sort by..."
className="[&>button]:!bg-background"
/>
</div>
<Button
variant="ghost"
variant="outline"
size="sm"
className="h-9 w-9 p-0 shrink-0"
onClick={() => onPrefsChange({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
@@ -204,6 +233,13 @@ export function OverviewToolbar({
options={VIEW_MODE_OPTIONS}
className="ml-auto shrink-0 shadow-card-bevel"
/>
{onAddNode && (
<Button size="sm" className="gap-1.5 shrink-0 h-9" onClick={onAddNode}>
<Plus className="w-4 h-4" strokeWidth={1.5} />
Add node
</Button>
)}
</div>
);
}
@@ -23,19 +23,47 @@ function props(overrides: Partial<React.ComponentProps<typeof OverviewToolbar>>
}
describe('OverviewToolbar', () => {
it('shows search and sort controls in grid mode', () => {
it('collapses the search to an icon button by default and expands it on click', () => {
render(<OverviewToolbar {...props()} />);
expect(screen.queryByPlaceholderText('Search nodes or stacks...')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Search nodes or stacks' }));
expect(screen.getByPlaceholderText('Search nodes or stacks...')).toBeInTheDocument();
});
it('renders the search expanded when a query is already active', () => {
render(<OverviewToolbar {...props({ searchQuery: 'plex' })} />);
expect(screen.getByDisplayValue('plex')).toBeInTheDocument();
});
it('focuses the input when the search expands', () => {
render(<OverviewToolbar {...props()} />);
fireEvent.click(screen.getByRole('button', { name: 'Search nodes or stacks' }));
expect(screen.getByPlaceholderText('Search nodes or stacks...')).toHaveFocus();
});
it('collapses back to the icon button on blur when the query is empty', () => {
render(<OverviewToolbar {...props()} />);
fireEvent.click(screen.getByRole('button', { name: 'Search nodes or stacks' }));
fireEvent.blur(screen.getByPlaceholderText('Search nodes or stacks...'));
expect(screen.queryByPlaceholderText('Search nodes or stacks...')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Search nodes or stacks' })).toBeInTheDocument();
});
it('stays expanded on blur while a query is active', () => {
render(<OverviewToolbar {...props({ searchQuery: 'plex' })} />);
fireEvent.blur(screen.getByPlaceholderText('Search nodes or stacks...'));
expect(screen.getByDisplayValue('plex')).toBeInTheDocument();
});
it('hides grid controls in topology mode', () => {
render(<OverviewToolbar {...props({ viewMode: 'topology' })} />);
expect(screen.queryByPlaceholderText('Search nodes or stacks...')).not.toBeInTheDocument();
});
it('forwards search input changes', () => {
it('forwards search input changes once expanded', () => {
const onSearchQueryChange = vi.fn();
render(<OverviewToolbar {...props({ onSearchQueryChange })} />);
fireEvent.click(screen.getByRole('button', { name: 'Search nodes or stacks' }));
fireEvent.change(screen.getByPlaceholderText('Search nodes or stacks...'), { target: { value: 'web' } });
expect(onSearchQueryChange).toHaveBeenCalledWith('web');
});
@@ -53,4 +81,16 @@ describe('OverviewToolbar', () => {
fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
});
it('renders the Add node button and fires onAddNode when provided', () => {
const onAddNode = vi.fn();
render(<OverviewToolbar {...props({ onAddNode })} />);
fireEvent.click(screen.getByRole('button', { name: 'Add node' }));
expect(onAddNode).toHaveBeenCalledTimes(1);
});
it('omits the Add node button when onAddNode is not provided', () => {
render(<OverviewToolbar {...props()} />);
expect(screen.queryByRole('button', { name: 'Add node' })).not.toBeInTheDocument();
});
});