mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -949,6 +949,7 @@ export default function EditorLayout() {
|
||||
headerActions={mobileMastheadActions}
|
||||
onNavigateToStack={handleSelectStack}
|
||||
onViewAllStacks={goToMobileList}
|
||||
onManageNodes={() => openSettings('nodes')}
|
||||
/>
|
||||
);
|
||||
case 'fleet':
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user