diff --git a/frontend/src/components/CapabilityGate.tsx b/frontend/src/components/CapabilityGate.tsx index 34f82a47..4386bc4a 100644 --- a/frontend/src/components/CapabilityGate.tsx +++ b/frontend/src/components/CapabilityGate.tsx @@ -8,6 +8,10 @@ import { LockCard } from './ui/LockCard'; interface CapabilityGateProps { capability: Capability; featureName?: string; + /** Overrides the default "Upgrade the node to use this feature." body. Use + * when the capability is gated on something other than node version (for + * example, a scanner binary that must be installed separately). */ + resolution?: string; children: ReactNode; } @@ -23,7 +27,7 @@ interface CapabilityGateProps { * dependency and adds no chunk weight, so a node that lacks the * capability never downloads the gated module. */ -export function CapabilityGate({ capability, featureName = 'This feature', children }: CapabilityGateProps) { +export function CapabilityGate({ capability, featureName = 'This feature', resolution, children }: CapabilityGateProps) { const { hasCapability, activeNode, activeNodeMeta } = useNodes(); if (hasCapability(capability)) return <>{children}; @@ -32,12 +36,13 @@ export function CapabilityGate({ capability, featureName = 'This feature', child const versionHint = isValidVersion(activeNodeMeta?.version) ? `${nodeName} is running v${activeNodeMeta.version}.` : `${nodeName} does not advertise this capability.`; + const body = resolution ?? `${versionHint} Upgrade the node to use this feature.`; return ( ); } diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 1c53aeb5..ea953611 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -949,6 +949,7 @@ export default function EditorLayout() { headerActions={mobileMastheadActions} onNavigateToStack={handleSelectStack} onViewAllStacks={goToMobileList} + onManageNodes={() => openSettings('nodes')} /> ); case 'fleet': diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 266863ed..4ca00e88 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -422,6 +422,17 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} const [imageSearch, setImageSearch] = useState(''); const [volumeSearch, setVolumeSearch] = useState(''); const [networkSearch, setNetworkSearch] = useState(''); + // Collapsible search: icon-only until clicked, stays open while query is active. + const [imageSearchExpanded, setImageSearchExpanded] = useState(false); + const [volumeSearchExpanded, setVolumeSearchExpanded] = useState(false); + const [networkSearchExpanded, setNetworkSearchExpanded] = useState(false); + const imageSearchRef = useRef(null); + const volumeSearchRef = useRef(null); + const networkSearchRef = useRef(null); + + useEffect(() => { if (imageSearchExpanded) imageSearchRef.current?.focus(); }, [imageSearchExpanded]); + useEffect(() => { if (volumeSearchExpanded) volumeSearchRef.current?.focus(); }, [volumeSearchExpanded]); + useEffect(() => { if (networkSearchExpanded) networkSearchRef.current?.focus(); }, [networkSearchExpanded]); // Modal states const [confirmPrune, setConfirmPrune] = useState<{ target: PruneTarget; scope: PruneScope } | null>(null); @@ -1002,11 +1013,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} Unmanaged {totalOrphansCount} - {totalOrphansCount > 0 && ( - - {totalOrphansCount} - - )} @@ -1014,20 +1020,35 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )} - +
{/* Images */}
-
- - setImageSearch(e.target.value)} - className="pl-9 h-9" - /> -
+ {imageSearch !== '' || imageSearchExpanded ? ( +
+ + setImageSearch(e.target.value)} + onBlur={() => { if (imageSearch === '') setImageSearchExpanded(false); }} + className="pl-9 h-9" + /> +
+ ) : ( + + + + + + Search images + + + )}
+ @@ -1186,21 +1208,37 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )}
+
{/* Volumes */}
-
- - setVolumeSearch(e.target.value)} - className="pl-9 h-9" - /> -
+ {volumeSearch !== '' || volumeSearchExpanded ? ( +
+ + setVolumeSearch(e.target.value)} + onBlur={() => { if (volumeSearch === '') setVolumeSearchExpanded(false); }} + className="pl-9 h-9" + /> +
+ ) : ( + + + + + + Search volumes + + + )}
+ @@ -1288,6 +1327,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )}
+
@@ -1296,15 +1336,30 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
{networkViewMode === 'list' ? ( <> -
- - setNetworkSearch(e.target.value)} - className="pl-9 h-9" - /> -
+ {networkSearch !== '' || networkSearchExpanded ? ( +
+ + setNetworkSearch(e.target.value)} + onBlur={() => { if (networkSearch === '') setNetworkSearchExpanded(false); }} + className="pl-9 h-9" + /> +
+ ) : ( + + + + + + Search networks + + + )} ) : (
+ @@ -1452,6 +1508,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )}
+
)} @@ -1541,7 +1598,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )}
- +
); diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index 828767f8..531dc35b 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -270,7 +270,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
- + - + - + @@ -320,7 +320,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security - + diff --git a/frontend/src/components/dashboard/StackHealthTable.tsx b/frontend/src/components/dashboard/StackHealthTable.tsx index ce8f40c7..89b7419f 100644 --- a/frontend/src/components/dashboard/StackHealthTable.tsx +++ b/frontend/src/components/dashboard/StackHealthTable.tsx @@ -22,8 +22,8 @@ const PAGE_SIZE = 8; // Shared by the header and data rows so their columns stay aligned. The // `max-md:min-w` keeps both at the same width below md, where the card scrolls // horizontally; desktop is unaffected by the `max-md:` prefix. Columns: -// dot · STACK · SOURCE · PORT · UP · CPU · MEM · CPU·10m · chevron. -const GRID_TEMPLATE = 'grid-cols-[14px_minmax(0,1fr)_64px_56px_52px_52px_72px_110px_16px] max-md:min-w-[640px]'; +// STACK · SOURCE · PORT · UP · CPU · MEM · CPU·10m · chevron. +const GRID_TEMPLATE = 'grid-cols-[minmax(0,1fr)_64px_56px_52px_52px_72px_110px_16px] max-md:min-w-[620px]'; const formatMemory = (mb: number): string => { if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; @@ -69,12 +69,6 @@ function formatUptime(seconds: number): string { return `${Math.max(1, Math.floor(seconds))}s`; } -const stateDot: Record = { - healthy: 'bg-success', - warn: 'bg-warning', - error: 'bg-destructive', -}; - const rowTint: Record = { healthy: '', warn: 'bg-warning/[0.04]', @@ -224,7 +218,6 @@ export function StackHealthTable({ ) : null}
- SOURCE PORT @@ -249,11 +242,10 @@ export function StackHealthTable({ }} className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-[var(--density-row-x)] py-[var(--density-row-y)] transition-colors hover:bg-accent/5 ${rowTint[row.state]}`} > -