fix: assorted UI polish across Home, Security, and Resources pages (#1590)

* fix: polish stack health table, security card heights, and scanner-gate copy

- Remove state dot column from Stack Health rows; state still signaled
  via row tinting and sparkline color
- Reposition Update available badge adjacent to stack name and track
  the theme accent (--brand) instead of static fuchsia
- Equalize Top exploit-risk findings and Severity x exploitability
  card heights via h-full in a stretching grid
- Add resolution prop to CapabilityGate so scanner-gated tabs show
  "Install a scanner from the Scanner setup tab." instead of the
  misleading "Upgrade the node" message

* fix: scope Resources scroll area to table contents only

- Remove bg-background from the outer wrapper so search/filter bars
  no longer sit on a dark strip
- Replace outer ScrollArea with a plain div so search and filter
  controls stay fixed while only table rows scroll
- Add ScrollArea (max-h-[62vh]) inside each table card for Images,
  Volumes, and Networks tabs

* fix: remove static bg-background from Security table scroll areas

Images and History tab tables used bg-background on their ScrollArea,
forcing a near-black fill that didn't adapt to the theme. Removing it
lets the parent card's bg-card show through, matching the Resources
table pattern that correctly respects DIM, OLED, and Light modes.

* fix: collapsible search icon and transparent Combobox across Resources and Security

- Replace always-visible search inputs on Resources tabs (Images,
  Volumes, Networks) and Security Images (desktop + mobile) with
  Fleet-style collapsible search: an icon button that expands to
  a full input on click, collapses on blur when empty
- Add [&>button]:!bg-background to Security Images severity
  Combobox, matching Fleet's transparent dropdown design

* fix: remove redundant orange badge from Unmanaged tab label

The count was shown twice: once as inline text in the tab and again
as an orange bg-warning pill badge. The inline text remains; the
duplicate badge is removed.

* fix: outline buttons invisible in Light mode

- Changed outline variant border from border-input to border so it
  uses --card-border, which maintains a visible 0.035 lightness gap
  from --background in Light mode (was 0.005, essentially invisible)
- Boosted Light-mode --button-inner-glow from a 4% white inset to
  a 10% inset plus a subtle drop shadow for the physical-key feel

* fix: bump table cell horizontal padding from 8px to density-aware token

Changed TableHead and TableCell px-2 (8px) to px-[var(--density-row-x)]
which resolves to 20px comfortable / 16px compact. Matches the density
system used by StackHealthTable and gives every shadcn-based table
proper breathing room on both edges.

* fix: expand collapsible search before typing in ImagesTab test

The test was written for the old always-visible search input. Now that
the search bar collapses to an icon, click it first to expand before
interacting with the input.

* fix: reorder Settings Infrastructure to Nodes, Stacks, Fleet

Move the Stacks section directly below Nodes so the Infrastructure
sidebar reads Nodes, Stacks, Fleet. Pure array-element relocation in
the settings registry; the sidebar renders in array order.

* fix: simplify pilot enroll command and add Step 2 copy button

Step 2 of the pilot-agent enrollment modal now shows "docker compose up
-d" instead of the redundant "-f compose.yaml" form, since Step 1
already has the operator save the file as the default compose.yaml. Add
an inline copy button next to the command so it can be copied without
selecting the text by hand.

* fix: stack Fleet/Security tab heading actions on mobile

The shared FleetTabHeading kept its title and action buttons on one row
at every width, so on phones the Security Suppressions heading and its
Export VEX / Add suppression buttons were crammed together. Stack the
heading and actions vertically below the md breakpoint and restore the
original row layout at md and up, so desktop is unchanged.

* feat: add node selector to mobile Home masthead

The mobile Home page showed the active node as static text, so switching
nodes was only possible from the Stacks page. Render the same compact
NodeSwitcher in the Home masthead kicker slot, giving a second place to
view and switch the active node with the identical popover and Manage
nodes action.
This commit is contained in:
Anso
2026-07-11 20:55:37 -04:00
committed by GitHub
parent 0e85b45569
commit 3fed67a3f1
17 changed files with 208 additions and 103 deletions
+7 -2
View File
@@ -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 (
<LockCard
icon={Unplug}
title={`${featureName} is not available on this node`}
body={`${versionHint} Upgrade the node to use this feature.`}
body={body}
/>
);
}
+1
View File
@@ -949,6 +949,7 @@ export default function EditorLayout() {
headerActions={mobileMastheadActions}
onNavigateToStack={handleSelectStack}
onViewAllStacks={goToMobileList}
onManageNodes={() => openSettings('nodes')}
/>
);
case 'fleet':
+91 -34
View File
@@ -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<HTMLInputElement>(null);
const volumeSearchRef = useRef<HTMLInputElement>(null);
const networkSearchRef = useRef<HTMLInputElement>(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 = {}
<TabsTrigger value="unmanaged" className="relative">
Unmanaged
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">{totalOrphansCount}</span>
{totalOrphansCount > 0 && (
<span className="absolute -top-1.5 -right-1 flex h-4 min-w-4 px-1 items-center justify-center rounded-full bg-warning text-[9px] text-warning-foreground font-medium animate-in zoom-in-75 duration-200">
{totalOrphansCount}
</span>
)}
</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
@@ -1014,20 +1020,35 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</div>
)}
<ScrollArea className="flex-1 bg-background relative text-sm">
<div className="flex-1 relative">
{/* Images */}
<TabsContent value="images" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
<div className="flex flex-wrap items-center gap-2 mb-4">
<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 images..."
value={imageSearch}
onChange={(e) => setImageSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{imageSearch !== '' || imageSearchExpanded ? (
<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={imageSearchRef}
placeholder="Search images..."
value={imageSearch}
onChange={(e) => setImageSearch(e.target.value)}
onBlur={() => { if (imageSearch === '') setImageSearchExpanded(false); }}
className="pl-9 h-9"
/>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setImageSearchExpanded(true)} aria-label="Search images">
<Search className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search images</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<FilterToggle
value={imageFilter}
onChange={setImageFilter}
@@ -1056,6 +1077,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
)}
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="max-h-[62vh]">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
@@ -1186,21 +1208,37 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</TableBody>
)}
</Table>
</ScrollArea>
</div>
</TabsContent>
{/* Volumes */}
<TabsContent value="volumes" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
<div className="flex flex-wrap items-center gap-2 mb-4">
<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 volumes..."
value={volumeSearch}
onChange={(e) => setVolumeSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{volumeSearch !== '' || volumeSearchExpanded ? (
<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={volumeSearchRef}
placeholder="Search volumes..."
value={volumeSearch}
onChange={(e) => setVolumeSearch(e.target.value)}
onBlur={() => { if (volumeSearch === '') setVolumeSearchExpanded(false); }}
className="pl-9 h-9"
/>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setVolumeSearchExpanded(true)} aria-label="Search volumes">
<Search className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search volumes</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<FilterToggle
value={volumeFilter}
onChange={setVolumeFilter}
@@ -1212,6 +1250,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
/>
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="max-h-[62vh]">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
@@ -1288,6 +1327,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</TableBody>
)}
</Table>
</ScrollArea>
</div>
</TabsContent>
@@ -1296,15 +1336,30 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
<div className="flex flex-wrap items-center gap-2 mb-4">
{networkViewMode === 'list' ? (
<>
<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 networks..."
value={networkSearch}
onChange={(e) => setNetworkSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{networkSearch !== '' || networkSearchExpanded ? (
<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={networkSearchRef}
placeholder="Search networks..."
value={networkSearch}
onChange={(e) => setNetworkSearch(e.target.value)}
onBlur={() => { if (networkSearch === '') setNetworkSearchExpanded(false); }}
className="pl-9 h-9"
/>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setNetworkSearchExpanded(true)} aria-label="Search networks">
<Search className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search networks</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<FilterToggle
value={networkFilter}
onChange={setNetworkFilter}
@@ -1377,6 +1432,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</div>
) : (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="max-h-[62vh]">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
@@ -1452,6 +1508,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</TableBody>
)}
</Table>
</ScrollArea>
</div>
)}
</TabsContent>
@@ -1541,7 +1598,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
)}
</div>
</TabsContent>
</ScrollArea>
</div>
</Tabs>
</>
);
+4 -4
View File
@@ -270,7 +270,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
</TabsContent>
<TabsContent value="images">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning" resolution="Install a scanner from the Scanner setup tab.">
<ImagesTab
summaries={summaries}
loading={summariesLoading}
@@ -285,13 +285,13 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
</TabsContent>
<TabsContent value="compose">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning" resolution="Install a scanner from the Scanner setup tab.">
<FindingsTab kind="misconfig" summaries={summaries} loading={summariesLoading} error={summariesError} onInspect={onInspect} />
</CapabilityGate>
</TabsContent>
<TabsContent value="secrets">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning" resolution="Install a scanner from the Scanner setup tab.">
<FindingsTab kind="secret" summaries={summaries} loading={summariesLoading} error={summariesError} onInspect={onInspect} />
</CapabilityGate>
</TabsContent>
@@ -320,7 +320,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
</TabsContent>
<TabsContent value="history">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning" resolution="Install a scanner from the Scanner setup tab.">
<HistoryTab onInspect={onInspect} />
</CapabilityGate>
</TabsContent>
@@ -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<RowState, string> = {
healthy: 'bg-success',
warn: 'bg-warning',
error: 'bg-destructive',
};
const rowTint: Record<RowState, string> = {
healthy: '',
warn: 'bg-warning/[0.04]',
@@ -224,7 +218,6 @@ export function StackHealthTable({
) : null}
</div>
<div className={`grid ${GRID_TEMPLATE} items-center gap-4 border-t border-border/60 px-[var(--density-row-x)] py-[var(--density-cell-y)] font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle`}>
<span />
<SortHeader label="STACK" k="stack" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<span>SOURCE</span>
<span>PORT</span>
@@ -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]}`}
>
<span className={`h-1.5 w-1.5 rounded-full justify-self-center ${stateDot[row.state]}`} aria-hidden="true" />
<span className="flex items-center gap-2 min-w-0">
<span className="flex-1 min-w-0 truncate font-mono text-sm text-stat-value">{row.name}</span>
<span className="flex items-center gap-1.5 min-w-0">
<span className="min-w-0 truncate font-mono text-sm text-stat-value">{row.name}</span>
{row.hasUpdate && (
<span className="shrink-0 rounded-full bg-update/15 px-2 py-0.5 font-mono text-[10px] leading-none text-update tracking-wide">
<span className="shrink-0 rounded-full bg-brand/15 px-2 py-0.5 font-mono text-[10px] leading-none text-brand tracking-wide">
Update available
</span>
)}
@@ -15,7 +15,7 @@ interface FleetTabHeadingProps {
*/
export function FleetTabHeading({ title, subtitle, action }: FleetTabHeadingProps) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="font-heading text-[1.5rem] leading-tight text-stat-value">{title}</h2>
<p className="text-sm text-stat-subtitle">{subtitle}</p>
@@ -4,6 +4,7 @@ import { useDashboardData } from '@/components/dashboard';
import { deriveHealth } from '@/components/dashboard/deriveHealth';
import type { HealthLevel, NotificationItem, StackCpuSeries, StackStatusEntry } from '@/components/dashboard/types';
import { Bar, Kicker, Masthead, MSparkline, SectionHead, StateDot } from './mobile-ui';
import { NodeSwitcher } from '@/components/NodeSwitcher';
interface MobileDashboardProps {
notifications: NotificationItem[];
@@ -11,6 +12,8 @@ interface MobileDashboardProps {
headerActions: ReactNode;
onNavigateToStack: (stackFile: string) => void;
onViewAllStacks: () => void;
/** Opens the Nodes settings section from the masthead node switcher. */
onManageNodes: () => void;
}
const SPARK_WINDOW_MS = 10 * 60 * 1000;
@@ -73,11 +76,10 @@ function StripCell({ label, value, bar }: { label: string; value: string; bar?:
);
}
export function MobileDashboard({ notifications, headerActions, onNavigateToStack, onViewAllStacks }: MobileDashboardProps) {
export function MobileDashboard({ notifications, headerActions, onNavigateToStack, onViewAllStacks, onManageNodes }: MobileDashboardProps) {
const { activeNode } = useNodes();
const data = useDashboardData();
const activeNodeName = activeNode?.name || 'Local';
const locality = activeNode?.type === 'remote' ? 'remote' : 'local';
// Re-render every few seconds so the "sync Xs" freshness label advances
// without a parent refetch, mirroring the desktop HealthStatusBar ticker.
@@ -142,7 +144,7 @@ export function MobileDashboard({ notifications, headerActions, onNavigateToStac
return (
<div className="flex h-full min-h-0 flex-col">
<Masthead
kicker={`${activeNodeName} · ${locality}`}
kickerSlot={<NodeSwitcher compact onManageNodes={onManageNodes} />}
state={LEVEL_LABEL[level]}
stateTone={LEVEL_TONE[level]}
live={level !== 'healthy' || data.metricsStale}
@@ -29,6 +29,8 @@ interface PilotEnrollment {
composeYaml: string;
}
const PILOT_AGENT_START_COMMAND = 'docker compose up -d';
interface NodeFormData {
name: string;
type: 'local' | 'remote';
@@ -552,8 +554,22 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
<div className="space-y-2">
<p className="text-xs font-medium text-foreground/80">Step 2: start the agent on the remote host</p>
<div className="rounded-md border border-card-border bg-muted/50 p-3">
<code className="text-xs font-mono text-foreground/90">docker compose -f compose.yaml up -d</code>
<div className="rounded-md border border-card-border bg-muted/50 p-3 flex items-center justify-between gap-2">
<code className="text-xs font-mono text-foreground/90">{PILOT_AGENT_START_COMMAND}</code>
<button
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
onClick={async () => {
try {
await copyToClipboard(PILOT_AGENT_START_COMMAND);
toast.success('Command copied to clipboard');
} catch {
toast.error('Copy failed.');
}
}}
aria-label="Copy start command"
>
<Copy className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
</div>
</div>
@@ -191,7 +191,7 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea block className="max-h-[60vh] bg-background">
<ScrollArea block className="max-h-[60vh]">
<Table className="max-md:min-w-[720px]">
<TableHeader>
<TableRow className="hover:bg-transparent">
+55 -21
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Boxes, AlertTriangle, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ShieldCheck, Loader2 } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
@@ -90,6 +90,10 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(0);
const [searchExpanded, setSearchExpanded] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (searchExpanded) searchInputRef.current?.focus(); }, [searchExpanded]);
// Apply an externally-driven filter (e.g. an overview "fixable" deep link).
// Keyed on the incoming value so re-navigating to the same filter re-applies.
@@ -166,15 +170,30 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
<div className="space-y-4">
{isMobile ? (
<>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<Input
placeholder="Filter images…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="pl-8"
/>
</div>
{search !== '' || searchExpanded ? (
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<Input
ref={searchInputRef}
placeholder="Filter images…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
onBlur={() => { if (search === '') setSearchExpanded(false); }}
className="pl-8"
/>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setSearchExpanded(true)} aria-label="Search images">
<Search className="w-4 h-4" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Search images</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<ImageFilterChips
chips={MOBILE_FILTER_CHIPS}
active={severity}
@@ -196,25 +215,40 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<Input
placeholder="Search images..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="pl-8"
/>
</div>
{search !== '' || searchExpanded ? (
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<Input
ref={searchInputRef}
placeholder="Search images..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
onBlur={() => { if (search === '') setSearchExpanded(false); }}
className="pl-8"
/>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setSearchExpanded(true)} aria-label="Search images">
<Search className="w-4 h-4" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Search images</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Combobox
options={FILTER_OPTIONS}
value={severity}
onValueChange={(v) => { setSeverity((v || 'all') as ImageFilterValue); setPage(0); }}
className="w-[200px]"
className="w-[200px] [&>button]:!bg-background"
/>
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<ScrollArea className="max-h-[62vh] bg-background">
<ScrollArea className="max-h-[62vh]">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
@@ -55,7 +55,7 @@ function StatusRow({ label, value, tone }: { label: string; value: string; tone?
function ChartCard({ title, className, children }: { title: string; className?: string; children: React.ReactNode }) {
return (
<div className={cn('rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4', className)}>
<div className={cn('rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-full', className)}>
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-3">{title}</h3>
{children}
</div>
@@ -234,10 +234,7 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
</ChartCard>
</div>
{/* items-start: each card keeps its natural height so the fixed-height chart
card never stretches to a taller exploit table (which left dead space
under the chart). The exploit-risk table owns its own card chrome. */}
<div className="grid items-start gap-4 lg:grid-cols-2">
<div className="grid gap-4 lg:grid-cols-2">
<TopExploitRiskList items={exploitIntel} truncated={exploitTruncated} onInspect={onInspect} />
<ChartCard title="Severity × exploitability">
<CvssEpssQuadrantChart items={exploitIntel} />
@@ -198,7 +198,7 @@ export function TopExploitRiskList({
const needsPagination = ranked.length > EXPLOIT_PAGE_SIZE;
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel max-md:overflow-x-auto">
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel max-md:overflow-x-auto h-full flex flex-col">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Top exploit-risk findings</h3>
{needsPagination && (
@@ -334,9 +334,9 @@ export function CvssEpssQuadrantChart({ items }: { items: ExploitIntelFinding[]
return (
// Fixed height, not flex-fill: a flex/grid-stretched ResponsiveContainer
// re-measures a content-driven height and grows on every render. The parent
// grid (OverviewTab) uses items-start so this card does not stretch to a
// taller neighbour, which is what previously left dead space under the chart.
// re-measures a content-driven height and grows on every render. Both cards
// in the parent grid stretch to the same height via h-full so dead space
// never accumulates under the chart.
<div>
<ChartContainer config={QUADRANT_CONFIG} className="h-[260px] w-full">
<ScatterChart margin={{ left: 12, right: 12, top: 8, bottom: 24 }}>
@@ -92,6 +92,7 @@ it('narrows the list with the search box', async () => {
)}
/>,
);
await userEvent.click(screen.getByLabelText('Search images'));
await userEvent.type(screen.getByPlaceholderText('Search images...'), 'redis');
expect(screen.getByText('redis:7')).toBeInTheDocument();
expect(screen.queryByText('nginx:1')).not.toBeInTheDocument();
+9 -9
View File
@@ -121,6 +121,15 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
scope: 'global',
hiddenOnRemote: true,
},
{
id: 'stacks',
group: 'infrastructure',
label: 'Stacks',
description: 'Stack editor, lifecycle workflow preferences, and deploy guardrails.',
keywords: ['stack', 'compose', 'deploy', 'guardrail', 'health gate', 'observation', 'env', 'required variable', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow'],
tier: null,
scope: 'node',
},
{
id: 'fleet-mesh',
group: 'infrastructure',
@@ -162,15 +171,6 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
tier: null,
scope: 'node',
},
{
id: 'stacks',
group: 'infrastructure',
label: 'Stacks',
description: 'Stack editor, lifecycle workflow preferences, and deploy guardrails.',
keywords: ['stack', 'compose', 'deploy', 'guardrail', 'health gate', 'observation', 'env', 'required variable', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow'],
tier: null,
scope: 'node',
},
// Monitoring
{
id: 'host-alerts',
+1 -1
View File
@@ -14,7 +14,7 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-btn-glow hover:bg-accent hover:text-accent-foreground",
"border border bg-background shadow-btn-glow hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-glass-highlight",
ghost: "hover:bg-glass-highlight hover:text-accent-foreground",
+2 -2
View File
@@ -73,7 +73,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-[calc(2.5rem*var(--density-scale,1))] px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"h-[calc(2.5rem*var(--density-scale,1))] px-[var(--density-row-x)] text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
@@ -88,7 +88,7 @@ const TableCell = React.forwardRef<
<td
ref={ref}
className={cn(
"px-2 py-[var(--density-cell-y)] align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"px-[var(--density-row-x)] py-[var(--density-cell-y)] align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
+1 -1
View File
@@ -386,7 +386,7 @@
/* Material simulation (light) */
--card-bevel: inset 0 1px 0 0 oklch(1 0 0 / 0.04), 0 1px 2px 0 oklch(0 0 0 / 0.05);
--button-inner-glow: inset 0 1px 0 0 oklch(1 0 0 / 0.04);
--button-inner-glow: inset 0 1px 0 0 oklch(1 0 0 / 0.10), 0 1px 2px 0 oklch(0 0 0 / 0.06);
--chrome-top-highlight: inset 0 1px 0 0 oklch(1 0 0 / 0.45);
/* Shadows (light) */