From bbcc4b59e45b72f8e2af1c07c8ed2c1740faa12d Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 6 Jul 2026 04:25:01 -0400 Subject: [PATCH] fix: UI polish - tooltips, masthead, tabs, settings, and layout consistency (#1563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: increase masthead rail shimmer visibility Bumped shimmer highlight opacity from 25% to 50% and reduced animation duration from 11s to 5s, matching the login page AuthCanvas shimmer precedent (via-white/60 at 4.5s). * fix: reduce masthead rail opacity to match login page pattern Changed all MastheadRail background classes from 100% to 70% opacity (bg-brand → bg-brand/70, etc.) so the shimmer highlight reads as a surface reflection rather than a detached floating bar. Matches the AuthCanvas login card precedent. * fix: improve masthead rail glow visibility Replaced the invisible inset box-shadow glow (10px blur inside a 3px element) with a white overlay that pulses opacity 0.15-0.55 over 4s. Sped up glow animation from 5.5s to 4s for a more noticeable breath. * fix: replace cursor-follow tooltip with standard Radix tooltip in sidebar rows Replaced the Cursor/CursorFollow animate-ui primitives in StackRow with the standard Radix Tooltip/TooltipTrigger/TooltipContent already used throughout the app. The custom cursor dot that followed the mouse is gone; tooltips now appear as static popovers on hover/touch. * fix: wrap tooltip icon triggers in span for Radix compatibility TooltipTrigger asChild requires a native element to forward props. Lucide icon components did not reliably receive data-state and event handlers, so tooltips on the check-failed and git-pending indicators did not open. Wrapped each icon in a span. * fix: rename Notification Routing to Routing in settings sidebar * fix: replace Timeline/All tasks buttons with SegmentedControl The two loose Button elements had indistinguishable selected/unselected states (secondary vs ghost). Replaced with the SegmentedControl component already used elsewhere on the same page, which has a clear active highlight indicator. * fix: standardize Blueprint empty state headings to font-heading Replaced manual font-serif [font-style:var(--heading-style)] with the font-heading class on the main heading and three step titles so the Deployments empty state follows the same heading convention as the rest of the app and respects the Calm/Signature theme toggle. * fix: remove custom tooltip styling, use standard TooltipContent defaults The RowTooltip wrapped its label in a font-mono text-stat-value span instead of using the TooltipContent base styling (text-xs text-popover-foreground) like every other tooltip in the app. * Revert "fix: remove custom tooltip styling, use standard TooltipContent defaults" This reverts commit 209fbd2742c45c57b0f23182d29a457cf0ebad6c. * fix: standardize all tooltips to match sidebar RowTooltip design Wrapped every TooltipContent child in a font-mono text-xs tabular-nums text-stat-value span so all tooltips across the app share a consistent look. Previously some used plain text, others used

elements with different fonts and sizes. * Revert "fix: standardize all tooltips to match sidebar RowTooltip design" This reverts commit 9e1d97d39e294a10ff06ec03e94aa751728d1342. * fix: bake standard tooltip styling into TooltipContent component Added font-mono tabular-nums text-stat-value directly to the TooltipContent base styles so every tooltip in the app inherits the same look without per-instance wrappers. Removed the now- redundant span from StackRow's RowTooltip. * fix: replace custom tooltip with shadcn/ui Radix tooltip Replaced the glass/blur-styled tooltip with the standard shadcn/ui pattern: bg-foreground/text-background, arrow pointer, proper slide/fade animations, and w-fit max-w-xs sizing. This gives all tooltips a consistent dark-solid look across the entire app. * fix: apply glass/blur design to shadcn tooltip component Replaced the solid bg-foreground/text-background style with the app's glass design: bg-popover, border-glass-border, backdrop-blur, and text-popover-foreground. Kept the shadcn structure (Portal, Arrow, slide/fade animations). * fix: replace native title tooltips with Radix tooltips on Fleet toolbar Converted Refresh, Export Dossier, Search, Sort direction, Node Update, and Manage Nodes buttons from native HTML title attributes to the shared Radix Tooltip component so they match the app-wide glass tooltip design. * fix: replace native title tooltip on Fleet Snapshots upload button * fix: replace native title tooltips with Radix tooltips on Resources page Converted all actionable button tooltips (Open stack, Inspect image, Scan, Browse volume, and delete buttons) plus the SenchoBadge informational tooltip. Wrapped disabled protected-resource delete buttons in span triggers so tooltips fire on disabled elements. * fix: replace native title tooltips with Radix tooltips on Security page Converted scanner info icon, exploitability n/a badge, and scan image button from native HTML title attributes to the shared Radix Tooltip component. * fix: replace native title tooltips with Radix tooltips on Schedules and Settings pages Converted Run now, Execution history, Edit, Delete buttons on Schedules; Download/Delete on Cloud Backup; Send test/Edit/Delete on Notification Routing; Reset 2FA on Users; and Edit/Remove suppression on Suppressions panel. Skipped SettingsSection/Modal title props which are React component props, not native HTML attributes. * feat: add copy button to execution history details column Replaced the plain truncated Details cell with a flex layout containing a copy button. Users can now copy the full error/output text instead of relying on the native title tooltip to read long messages. * fix: change tab highlight from neutral to brand color Replaced bg-glass-highlight with bg-brand/20 on all TabsHighlight instances and updated TabsTrigger active state to text-brand, matching the SegmentedControl selected style. Works on both dark and light themes. * fix: restructure Resources page tabs to match Fleet/Security pattern Moved tab band out of the outer card into a standalone full-width element. Replaced custom FilterToggle with Fleet-style Button toggles. Added search inputs per tab. Wrapped each resource table in a card container matching the Security Images table design. * fix: match Resources tab font size to Fleet and Security Removed explicit text-xs from Resources TabsTrigger elements so they use the default text-sm, matching Fleet and Security tab typography. * fix: restore missing closing bracket on TabsTrigger tag * fix: add aria-labels to schedule action buttons, update test selectors Replaced native title attributes with aria-labels on Run now, Execution history, Edit, and Delete buttons so the Radix tooltip conversion does not break test selectors that relied on title. Updated tests from findByTitle to findByRole. * fix: add fingerprint-based dismiss to post-deploy scan banner Created useScanBannerDismiss hook following the usePreflightDismiss pattern. The banner now persists dismissal in localStorage keyed to a fingerprint of scan status + attemptedAt. Dismissal survives page reloads for the same scan outcome, and the banner automatically reappears when a new scan runs or the status changes. * fix: update Configuration Status card labels and add new rows Notifications section: renamed Notification agents to Channels, Notification routing to Routing, added Mute Rules row linking to notification-suppression settings. Security section: added Trivy installed Yes/No row, renamed Vulnerability scanning to Scan policies. Backend: added trivyInstalled and suppressionRules fields to the /dashboard/configuration payload. * fix: style Trivy row as badge and link to Security scanner setup * fix: remove Mute button from stack anatomy panel header * Revert "fix: remove Mute button from stack anatomy panel header" This reverts commit 16843a81b5430e831b7c5e687b2158d717d44dc4. * fix: remove Mute button from stack anatomy panel header * fix: remove unused stackMuteActions imports and props from StackAnatomyPanel The removal of the ActivityMuteKebab rendering left orphaned imports and props that cause TS6133 errors under strict mode. Clean up the imports, prop type, destructuring, and caller prop passes. * fix: refine anatomy tab sizing and add pulsing scroll chevron Changed anatomy tabs from 12px fixed height to 11px with py-1 padding, matching the Fleet tab approach. Bumped Files/Edit buttons from 10px to xs for better visibility. Added subtle animate-pulse to the right scroll chevron to hint at overflow tabs, gated behind the Reduced effects appearance setting. * fix: link reclaim hero number to heading style * fix: replace native title tooltips with Radix tooltips on stack detail page Converted Expand/Collapse logs, Stats unavailable, Copy service URL, Dismiss findings, Likely secret badge, Move up/down, and Remove buttons from native HTML title attributes to the shared Radix glass tooltip component. * fix: replace native title tooltip on image source link button * fix: add tooltips to container logs and bash session buttons * fix: add tooltip to download logs button * fix: add tooltip to copy digest button * fix: replace cursor-follow tooltip with Radix tooltip on severity badge * fix: add tooltips to density toggle buttons * feat: add expand button to multi-container health panel Added an expand/collapse toggle next to the density controls that expands the containers panel to fill the column and hides the logs section. The expand and logs-expand are mutually exclusive. Only appears for stacks with more than one container. * fix: reduce Structured/Raw terminal toggle font to 10px * fix: set compact density as default for multi-container stacks * fix: change Feedback to Open New Issue with GitHub issues link * fix: replace ExternalLink with GitHub icon for Open New Issue menu item * fix: restore ExternalLink import needed by other menu items * fix: default host threshold alerts to off for new installations * fix: resolve CI typecheck and test failures Broaden MenuRowProps.icon type to accept custom SVG components alongside lucide icons, fixing TS2741 on GitHubIcon import. Update host_alerts_enabled seed test assertion to match the new opt-in default of off (0). * fix: update ContainersHealth tests for compact default density Adjust three tests to reflect the new default of compact mode: - Rename 'detailed mode is the default' to 'compact mode is the default' - Update sparkline visibility checks to switch to detailed first - Rename remount test and verify reset to compact (new default) --- backend/src/__tests__/settings-routes.test.ts | 4 +- backend/src/routes/dashboard.ts | 8 + backend/src/services/DatabaseService.ts | 2 +- .../components/EditorLayout/EditorView.tsx | 35 +- .../EditorLayout/MobileStackDetail.tsx | 1 - .../__tests__/ContainersHealth.test.tsx | 27 +- .../EditorLayout/editor-view-blocks.tsx | 226 +++++++---- frontend/src/components/FleetSnapshots.tsx | 35 +- frontend/src/components/FleetView.tsx | 59 +-- .../components/FleetView/OverviewToolbar.tsx | 107 +++-- frontend/src/components/ImageSourceMenu.tsx | 33 +- frontend/src/components/ResourcesView.tsx | 369 +++++++++++------- .../components/ScheduledOperationsView.tsx | 112 ++++-- frontend/src/components/SecurityView.tsx | 20 +- frontend/src/components/StackAnatomyPanel.tsx | 44 ++- .../src/components/StructuredLogViewer.tsx | 46 ++- .../src/components/UserProfileDropdown.tsx | 10 +- .../ScheduledOperationsView.test.tsx | 28 +- .../blueprints/BlueprintEmptyState.tsx | 4 +- .../dashboard/ConfigurationStatus.tsx | 26 +- .../components/dashboard/HealthStatusBar.tsx | 6 +- .../__tests__/ConfigurationStatus.test.tsx | 14 +- .../dashboard/useConfigurationStatus.ts | 2 + .../src/components/fleet/FleetMasthead.tsx | 6 +- .../src/components/resources/ReclaimHero.tsx | 2 +- .../src/components/security/ImagesTab.tsx | 35 +- .../components/security/SecurityCharts.tsx | 10 +- .../settings/CloudBackupSection.tsx | 83 ++-- .../settings/NotificationRoutingSection.tsx | 74 ++-- .../settings/NotificationsSection.tsx | 2 +- .../components/settings/SuppressionsPanel.tsx | 49 ++- .../src/components/settings/UsersSection.tsx | 23 +- .../settings/__tests__/registry.test.ts | 2 +- frontend/src/components/settings/registry.ts | 2 +- frontend/src/components/sidebar/StackRow.tsx | 35 +- .../sidebar/__tests__/StackRow.test.tsx | 20 +- .../src/components/stack/EnvironmentPanel.tsx | 93 +++-- .../components/stack/GitSourceDiffDialog.tsx | 2 +- .../src/components/stack/PreflightPanel.tsx | 27 +- frontend/src/components/ui/GitHubIcon.tsx | 20 + frontend/src/components/ui/MastheadRail.tsx | 4 +- frontend/src/components/ui/PageMasthead.tsx | 8 +- .../src/components/ui/ScrollableTabRow.tsx | 2 +- frontend/src/components/ui/SeverityBadge.tsx | 19 +- frontend/src/components/ui/tabs.tsx | 2 +- frontend/src/components/ui/tooltip.tsx | 82 ++-- frontend/src/hooks/useScanBannerDismiss.ts | 60 +++ frontend/src/index.css | 11 +- 48 files changed, 1209 insertions(+), 682 deletions(-) create mode 100644 frontend/src/components/ui/GitHubIcon.tsx create mode 100644 frontend/src/hooks/useScanBannerDismiss.ts diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index 16663fff..305f3e7c 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -378,8 +378,8 @@ describe('env_block_deploy_on_missing_required setting', () => { }); describe('host_alerts_enabled toggle', () => { - it('seeds to "1" (on) in a fresh database', () => { - expect(DatabaseService.getInstance().getGlobalSettings().host_alerts_enabled).toBe('1'); + it('seeds to "0" (off) in a fresh database', () => { + expect(DatabaseService.getInstance().getGlobalSettings().host_alerts_enabled).toBe('0'); }); it('is exposed through the settings GET projection', async () => { diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 5bc7e1b2..12c192af 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService, type StackRestartSummary } from '../services/DatabaseService'; import { CloudBackupService } from '../services/CloudBackupService'; +import TrivyService from '../services/TrivyService'; import { effectiveTier } from '../middleware/tierGates'; import { isDebugEnabled } from '../utils/debug'; import type { LicenseTier } from '../services/license-types'; @@ -18,6 +19,7 @@ export interface ConfigurationStatus { agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus }; alertRules: number; routingRules: { count: number; enabledCount: number; locked: boolean }; + suppressionRules: { total: number; enabledCount: number }; }; automation: { autoHeal: { total: number; enabled: number }; @@ -29,6 +31,7 @@ export interface ConfigurationStatus { mfaEnabled: boolean | null; ssoEnabled: boolean; ssoProvider: string | null; + trivyInstalled: boolean; scanPolicies: { total: number; enabled: number; locked: boolean }; }; thresholds: { @@ -101,6 +104,10 @@ export function buildLocalConfigurationStatus( enabledCount: notifRoutes.filter(r => r.enabled).length, locked: false, }, + suppressionRules: (() => { + const rules = db.getNotificationSuppressionRules(); + return { total: rules.length, enabledCount: rules.filter(r => r.enabled).length }; + })(), }, automation: { autoHeal: { @@ -128,6 +135,7 @@ export function buildLocalConfigurationStatus( mfaEnabled: mfaRow ? mfaRow.enabled === 1 : null, ssoEnabled: !!enabledSso, ssoProvider: enabledSso?.provider ?? null, + trivyInstalled: TrivyService.getInstance().getSource() !== 'none', // Scan policies are available on every tier. scanPolicies: { total: scanPolicies.length, diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 9a61ae9b..22ede216 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1679,7 +1679,7 @@ export class DatabaseService { stmt.run('host_ram_limit', '90'); stmt.run('host_disk_limit', '90'); stmt.run('host_alert_suppression_mins', '60'); - stmt.run('host_alerts_enabled', '1'); + stmt.run('host_alerts_enabled', '0'); stmt.run('global_crash', '1'); stmt.run('docker_janitor_gb', '5'); stmt.run('developer_mode', '0'); diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 801491f2..831bc2ad 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -341,6 +341,23 @@ export function EditorView(props: EditorViewProps) { if (logsMode === 'raw') setLogsExpanded(false); }, [logsMode]); + // Expand containers to fill the column, hiding logs. Toggled from the + // density toggle row in ContainersHealth (multi-container stacks only). + const [containersExpanded, setContainersExpanded] = useState(false); + // Mutually exclusive: expanding one collapses the other. + const toggleContainersExpand = () => { + setContainersExpanded((prev) => { + if (!prev) setLogsExpanded(false); + return !prev; + }); + }; + const toggleLogsExpand = () => { + setLogsExpanded((prev) => { + if (!prev) setContainersExpanded(false); + return !prev; + }); + }; + // Below md, render the segmented full-screen mobile detail instead of the // desktop two-pane grid. All hooks above run unconditionally before this // branch so hook order stays stable across breakpoints. @@ -359,7 +376,7 @@ export function EditorView(props: EditorViewProps) { {/* Command Center Card (identity + health strip). Hidden when the logs are expanded so the logs pane fills the column. */} {!logsExpanded && ( - 1 ? 'flex flex-col min-h-0 max-h-[42%]' : 'shrink-0'}`}> + 1 && !containersExpanded ? 'flex flex-col min-h-0 max-h-[42%]' : safeContainers.length > 1 && containersExpanded ? 'flex flex-col flex-1 min-h-0' : 'shrink-0'}`}>

@@ -424,6 +441,8 @@ export function EditorView(props: EditorViewProps) { openLogViewer={openLogViewer} openBashModal={openBashModal} serviceAction={serviceAction} + containersExpanded={containersExpanded} + onToggleContainersExpand={toggleContainersExpand} key={`${activeNode?.id ?? 'local'}:${stackName}`} /> @@ -447,15 +466,16 @@ export function EditorView(props: EditorViewProps) { )} {/* Logs Section (fills remaining left-column height). On multi- - container stacks a min-h guarantees logs are never hidden. */} - {safeContainers.length > 1 ? ( + container stacks a min-h guarantees logs are never hidden. + Hidden when containers are expanded to fill the column. */} + {!containersExpanded && (safeContainers.length > 1 ? (
setLogsExpanded((v) => !v)} + onToggleLogsExpand={toggleLogsExpand} />
) : ( @@ -464,9 +484,9 @@ export function EditorView(props: EditorViewProps) { logsMode={logsMode} setLogsMode={setLogsMode} logsExpanded={logsExpanded} - onToggleLogsExpand={() => setLogsExpanded((v) => !v)} + onToggleLogsExpand={toggleLogsExpand} /> - )} + ))}
)} @@ -477,7 +497,7 @@ export function EditorView(props: EditorViewProps) {
setActiveTab(value as 'compose' | 'env' | 'files')}> - + compose.yaml @@ -658,7 +678,6 @@ export function EditorView(props: EditorViewProps) { applying={loadingAction === 'update'} canEdit={can('stack:edit', 'stack', stackName)} notifications={notifications} - stackMuteActions={stackMuteActions} /> )}
diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index f4488c90..4066c9df 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -248,7 +248,6 @@ export function MobileStackDetail(props: EditorViewProps) { applying={loadingAction === 'update'} canEdit={canEditStack} notifications={notifications} - stackMuteActions={stackMuteActions} />
)} diff --git a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx index 0662d70a..df9abc1a 100644 --- a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx @@ -140,19 +140,23 @@ describe('density toggle and summary strip', () => { expect(screen.getByRole('button', { name: 'Detailed view' })).toBeInTheDocument(); }); - it('detailed mode is the default', () => { + it('compact mode is the default', () => { renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]); - const detailed = screen.getByRole('button', { name: 'Detailed view' }); - expect(detailed).toHaveAttribute('aria-pressed', 'true'); + const compact = screen.getByRole('button', { name: 'Compact view' }); + expect(compact).toHaveAttribute('aria-pressed', 'true'); }); it('hides sparkline grids in compact mode', () => { renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]); - // Sparklines visible by default in detailed mode (two containers, two cpu labels) + // Sparklines hidden by default (compact is the default) + expect(screen.queryByText('cpu')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Detailed view' })); + // Sparklines visible after switching to detailed expect(screen.getAllByText('cpu')).toHaveLength(2); fireEvent.click(screen.getByRole('button', { name: 'Compact view' })); - // Sparkline labels hidden in compact mode + // Sparkline labels hidden again in compact mode expect(screen.queryByText('cpu')).toBeNull(); }); @@ -181,7 +185,7 @@ describe('density toggle and summary strip', () => { expect(screen.queryByRole('button', { name: 'Detailed view' })).toBeNull(); }); - it('resets density to detailed on remount (key change)', () => { + it('resets density to compact on remount (key change)', () => { const { unmount } = render( { serviceAction={vi.fn()} />, ); - // Switch to compact - fireEvent.click(screen.getByRole('button', { name: 'Compact view' })); - expect(screen.queryByText('cpu')).toBeNull(); + // Switch to detailed + fireEvent.click(screen.getByRole('button', { name: 'Detailed view' })); + expect(screen.getAllByText('cpu')).toHaveLength(2); // Simulate navigating to a single-container stack (new key) unmount(); @@ -212,8 +216,9 @@ describe('density toggle and summary strip', () => { serviceAction={vi.fn()} />, ); - // Density reset; single container shows sparklines - expect(screen.getByText('cpu')).toBeInTheDocument(); + // Density reset to compact; single container hides sparklines, + // no density toggle for a single container + expect(screen.queryByText('cpu')).toBeNull(); expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull(); }); }); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index 1ec084ed..e0e21167 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -18,6 +18,8 @@ import { CloudDownload, Layers, List, + Maximize2, + Minimize2, } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '../ui/button'; @@ -35,6 +37,7 @@ import { Sparkline } from '../ui/sparkline'; import { ImageSourceMenu } from '../ImageSourceMenu'; import { cn } from '@/lib/utils'; import { copyToClipboard } from '@/lib/clipboard'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { buildServiceUrl } from '@/lib/serviceUrl'; import ErrorBoundary from '../ErrorBoundary'; import TerminalComponent from '../Terminal'; @@ -199,30 +202,37 @@ export function StackIdentityHeader({ <> · digest {digest} - + + + + + + Copy digest + + )} @@ -329,6 +339,8 @@ export interface ContainersHealthProps { openLogViewer: (containerId: string, containerName: string) => void; openBashModal: (containerId: string, containerName: string) => void; serviceAction: (action: 'start' | 'stop' | 'restart', serviceName: string) => Promise; + containersExpanded?: boolean; + onToggleContainersExpand?: () => void; } // Per-container health strip: status badge, uptime, ports, and CPU/Mem/Net @@ -342,12 +354,14 @@ export function ContainersHealth({ openLogViewer, openBashModal, serviceAction, + containersExpanded, + onToggleContainersExpand, }: ContainersHealthProps) { const [copiedUrlId, setCopiedUrlId] = useState(null); const copiedUrlTimerRef = useRef(null); // Compact mode hides sparkline grids across all containers for a denser // list. Detailed mode (default) shows CPU / Mem / Net per container. - const [density, setDensity] = useState<'compact' | 'detailed'>('detailed'); + const [density, setDensity] = useState<'compact' | 'detailed'>('compact'); useEffect(() => () => { if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current); }, []); @@ -366,12 +380,16 @@ export function ContainersHealth({
{containerStatsError && safeContainers.length > 0 && (
- - Stats unavailable - + + + + + Stats unavailable + + + {containerStatsError} + +
)} {safeContainers.length === 0 ? ( @@ -393,25 +411,61 @@ export function ContainersHealth({ {paused > 0 && {paused} paused} {unhealthy > 0 && {unhealthy} unhealthy}
-
- - +
+
+ + + + + + Compact view + + + + + + + + Detailed view + + + {onToggleContainersExpand && ( + + + + + + {containersExpanded ? 'Collapse containers' : 'Expand containers'} + + + )} +
); @@ -483,19 +537,25 @@ export function ContainersHealth({ > {portLabel} - + + Copy service URL + + ) : ( {portLabel} @@ -511,27 +571,41 @@ export function ContainersHealth({ imageId={container.ImageID} className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11" /> - - {isAdmin && ( - + aria-label="View logs" + > + + + + View logs + + + {isAdmin && ( + + + + + + Open bash shell + + )} {container.Service && ( @@ -627,7 +701,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode, logsExpande type="button" onClick={() => setLogsMode('structured')} className={cn( - 'rounded px-2 py-0.5 font-mono text-xs uppercase tracking-wide transition-colors', + 'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors', logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground', )} > @@ -637,7 +711,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode, logsExpande type="button" onClick={() => setLogsMode('raw')} className={cn( - 'rounded px-2 py-0.5 font-mono text-xs uppercase tracking-wide transition-colors', + 'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors', logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground', )} > diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index bfeeb390..5c8b9838 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -18,6 +18,7 @@ import { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { toast } from '@/components/ui/toast-store'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { FleetTabHeading, FleetEmptyState, FleetEmptyCard } from './fleet/FleetEmptyState'; // --- Types --- @@ -839,20 +840,26 @@ export default function FleetSnapshots() { View {isAdmin && cloudEnabled && !cloudSnapshotIds.has(snapshot.id) && ( - + + + + + + Upload to cloud + + )} {isAdmin && ( + + + + + + Refresh + + {isAdmin && ( - + + + + + + Export Dossier + + )} diff --git a/frontend/src/components/FleetView/OverviewToolbar.tsx b/frontend/src/components/FleetView/OverviewToolbar.tsx index 2df5cae2..f318eecb 100644 --- a/frontend/src/components/FleetView/OverviewToolbar.tsx +++ b/frontend/src/components/FleetView/OverviewToolbar.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Combobox } from '@/components/ui/combobox'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { SegmentedControl, type SegmentedControlOption } from '@/components/ui/segmented-control'; import { LabelDot } from '../LabelPill'; import type { LabelColor } from '../label-types'; @@ -110,16 +111,22 @@ export function OverviewToolbar({ /> ) : ( - + + + + + + Search nodes or stacks + + )}
- + + + + + + {prefs.sortDir === 'asc' ? 'Switch to descending' : 'Switch to ascending'} + + + + + + + + Check for node updates + + )} {onAddNode && ( - + + + + + + Manage nodes + + )} ); diff --git a/frontend/src/components/ImageSourceMenu.tsx b/frontend/src/components/ImageSourceMenu.tsx index af21092b..236fdc1d 100644 --- a/frontend/src/components/ImageSourceMenu.tsx +++ b/frontend/src/components/ImageSourceMenu.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { Link2, ExternalLink, Copy, Check } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { DropdownMenu, DropdownMenuContent, @@ -98,19 +99,25 @@ export function ImageSourceMenu({ imageRef, imageId, className = 'h-4 w-4' }: Im return ( - - - + + + + + + + + Image source links + + {trimmedRef} diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 6e5dde6b..5d2d175b 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -13,7 +13,9 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; -import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Loader2, History, FolderOpen } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Loader2, History, FolderOpen, Search } from 'lucide-react'; +import { Input } from '@/components/ui/input'; import { SeverityBadge } from '@/components/ui/SeverityBadge'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; @@ -143,29 +145,19 @@ function FilterToggle({ value, onChange, counts }: FilterToggleProps) { ]; return ( -
-
- {options.map(({ key, label, count }) => ( - - ))} -
+
+ {options.map(({ key, label, count }) => ( + + ))}
); } @@ -183,9 +175,16 @@ function ManagedBadge({ status, managedBy, onOpenStack }: { const inner = (<>{managedBy}); if (onOpenStack && managedBy) { return ( - + + + + + + Open stack {managedBy} + + ); } return {inner}; @@ -213,13 +212,17 @@ function ManagedBadge({ status, managedBy, onOpenStack }: { function SenchoBadge() { return ( - - - Sencho - + + + + + + Sencho + + + Protected · running Sencho instance + + ); } @@ -340,6 +343,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} const [volumeFilter, setVolumeFilter] = useState('all'); const [networkFilter, setNetworkFilter] = useState('all'); + // Search state + const [imageSearch, setImageSearch] = useState(''); + const [volumeSearch, setVolumeSearch] = useState(''); + const [networkSearch, setNetworkSearch] = useState(''); + // Modal states const [confirmPrune, setConfirmPrune] = useState<{ target: PruneTarget; scope: PruneScope } | null>(null); const [confirmDelete, setConfirmDelete] = useState<{ type: 'images' | 'volumes' | 'networks'; id: string; name?: string } | null>(null); @@ -617,16 +625,19 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} // Derived filtered lists const filteredImages = images.filter(img => - imageFilter === 'managed' ? img.managedStatus === 'managed' : - imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true + (imageFilter === 'managed' ? img.managedStatus === 'managed' : + imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true) && + (imageSearch === '' || (img.RepoTags?.[0] || '').toLowerCase().includes(imageSearch.toLowerCase())) ); const filteredVolumes = volumes.filter(vol => - volumeFilter === 'managed' ? vol.managedStatus === 'managed' : - volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true + (volumeFilter === 'managed' ? vol.managedStatus === 'managed' : + volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true) && + (volumeSearch === '' || vol.Name.toLowerCase().includes(volumeSearch.toLowerCase())) ); const filteredNetworks = networks.filter(net => - networkFilter === 'managed' ? net.managedStatus === 'managed' : - networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true + (networkFilter === 'managed' ? net.managedStatus === 'managed' : + networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true) && + (networkSearch === '' || net.Name.toLowerCase().includes(networkSearch.toLowerCase())) ); // Sortable tables (standard sort behavior across the resource tables). @@ -805,7 +816,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} setResourceTab(v as typeof resourceTab)} - className="flex-1 flex flex-col w-full rounded-lg border bg-card shadow-card-bevel overflow-hidden min-h-[400px] animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-150" + className="flex-1 flex flex-col w-full min-h-[400px]" > {isMobile ? ( ) : ( -
- - +
+ + {(['images', 'volumes', 'networks'] as const).map(tab => ( - - {tab} + + {tab.charAt(0).toUpperCase() + tab.slice(1)} + + {tab === 'images' ? images.length : tab === 'volumes' ? volumes.length : networks.length} + ))} - + Unmanaged + {totalOrphansCount} {totalOrphansCount > 0 && ( {totalOrphansCount} @@ -849,7 +864,16 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} {/* Images */} -
+
+
+ + setImageSearch(e.target.value)} + className="pl-9 h-9" + /> +
i.managedStatus !== 'managed').length, }} /> +
{trivy.available && ( )}
+
@@ -917,33 +942,45 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
- - {trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( - - + + + - + + Inspect image + + + {trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( + + + + + + + + + Scan for vulnerabilities + + handleScanImage(img.RepoTags![0], { scanners: ['vuln'] })} @@ -958,16 +995,26 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )} - {isAdmin && } + {isAdmin && ( + + + + + + + + {img.isSencho && Protected · running Sencho instance} + + + )}
@@ -975,19 +1022,32 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )}
+
{/* Volumes */} - v.managedStatus === 'managed').length, - unmanaged: volumes.filter(v => v.managedStatus !== 'managed').length, - }} - /> +
+
+ + setVolumeSearch(e.target.value)} + className="pl-9 h-9" + /> +
+ v.managedStatus === 'managed').length, + unmanaged: volumes.filter(v => v.managedStatus !== 'managed').length, + }} + /> +
+
@@ -1020,27 +1080,43 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
{isAdmin && ( - + + + + + + Browse volume contents + + + )} + {isAdmin && ( + + + + + + + + {vol.isSencho && Protected · running Sencho instance} + + )} - {isAdmin && }
@@ -1048,27 +1124,38 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )}
+
{/* Networks */} -
+
{networkViewMode === 'list' ? ( - n.managedStatus === 'managed').length, - unmanaged: networks.filter(n => n.managedStatus !== 'managed').length, - }} - /> + <> +
+ + setNetworkSearch(e.target.value)} + className="pl-9 h-9" + /> +
+ n.managedStatus === 'managed').length, + unmanaged: networks.filter(n => n.managedStatus !== 'managed').length, + }} + /> + ) : ( - // Keep a left spacer so justify-between holds the toggle + - // Create Network group anchored on the right in topology mode.
-

+

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/dashboard/ConfigurationStatus.tsx b/frontend/src/components/dashboard/ConfigurationStatus.tsx index 947f5409..a0856acf 100644 --- a/frontend/src/components/dashboard/ConfigurationStatus.tsx +++ b/frontend/src/components/dashboard/ConfigurationStatus.tsx @@ -11,17 +11,17 @@ interface ConfigurationStatusProps { function StatusBadge({ value }: { value: string }) { const lower = value.toLowerCase(); - if (lower === 'on' || lower === 'enabled') { + if (lower === 'on' || lower === 'enabled' || lower === 'installed') { return ( - ON + {lower === 'installed' ? 'INSTALLED' : 'ON'} ); } - if (lower === 'off' || lower === 'disabled') { + if (lower === 'off' || lower === 'disabled' || lower === 'not installed') { return ( - OFF + {lower === 'not installed' ? 'NOT INSTALLED' : 'OFF'} ); } @@ -141,7 +141,7 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
- + {!notifications.routingRules.locked && ( )} + + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security', tab: 'scanner' } }), + )} + /> {!security.scanPolicies.locked && ( window.dispatchEvent( new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security', tab: 'policies' } }), diff --git a/frontend/src/components/dashboard/HealthStatusBar.tsx b/frontend/src/components/dashboard/HealthStatusBar.tsx index 5c7f20a5..02fa0fad 100644 --- a/frontend/src/components/dashboard/HealthStatusBar.tsx +++ b/frontend/src/components/dashboard/HealthStatusBar.tsx @@ -20,19 +20,19 @@ const healthConfig: Record = {}): Confi }, alertRules: 0, routingRules: { count: 0, enabledCount: 0, locked: true }, + suppressionRules: { total: 0, enabledCount: 0 }, }, automation: { autoHeal: { total: 0, enabled: 0 }, @@ -31,6 +32,7 @@ function makePayload(overrides: Partial = {}): Confi mfaEnabled: null, ssoEnabled: false, ssoProvider: null, + trivyInstalled: false, scanPolicies: { total: 0, enabled: 0, locked: false }, }, thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true }, @@ -68,11 +70,11 @@ describe('ConfigurationStatus row visibility', () => { expect(screen.getByText('Auto-heal policies')).toBeDefined(); expect(screen.getByText('Auto-update schedules')).toBeDefined(); // Rows whose payload reports locked stay hidden. - expect(screen.queryByText('Notification routing')).toBeNull(); + expect(screen.queryByText('Routing')).toBeNull(); expect(screen.queryByText('Webhooks')).toBeNull(); expect(screen.queryByText('Scheduled tasks')).toBeNull(); - // Scan policies are free, so the Vulnerability scanning row renders. - expect(screen.getByText('Vulnerability scanning')).toBeDefined(); + // Scan policies are free, so the row renders. + expect(screen.getByText('Scan policies')).toBeDefined(); // Cloud Backup row is universal (Custom S3 is open to every tier). expect(screen.getByText('Cloud Backup')).toBeDefined(); }); @@ -89,6 +91,7 @@ describe('ConfigurationStatus row visibility', () => { }, alertRules: 2, routingRules: { count: 1, enabledCount: 1, locked: false }, + suppressionRules: { total: 0, enabledCount: 0 }, }, automation: { autoHeal: { total: 3, enabled: 2 }, @@ -100,6 +103,7 @@ describe('ConfigurationStatus row visibility', () => { mfaEnabled: true, ssoEnabled: true, ssoProvider: 'oidc_google', + trivyInstalled: true, scanPolicies: { total: 2, enabled: 2, locked: false }, }, }), @@ -110,10 +114,10 @@ describe('ConfigurationStatus row visibility', () => { expect(screen.getByText('Automation')).toBeDefined(); expect(screen.getByText('Auto-heal policies')).toBeDefined(); expect(screen.getByText('Auto-update schedules')).toBeDefined(); - expect(screen.getByText('Notification routing')).toBeDefined(); + expect(screen.getByText('Routing')).toBeDefined(); expect(screen.getByText('Webhooks')).toBeDefined(); expect(screen.getByText('Scheduled tasks')).toBeDefined(); - expect(screen.getByText('Vulnerability scanning')).toBeDefined(); + expect(screen.getByText('Scan policies')).toBeDefined(); expect(screen.getByText('Cloud Backup')).toBeDefined(); // SSO label maps the provider to a friendly name. expect(screen.getByText('Google')).toBeDefined(); diff --git a/frontend/src/components/dashboard/useConfigurationStatus.ts b/frontend/src/components/dashboard/useConfigurationStatus.ts index ea94c0f9..960ed3cc 100644 --- a/frontend/src/components/dashboard/useConfigurationStatus.ts +++ b/frontend/src/components/dashboard/useConfigurationStatus.ts @@ -18,6 +18,7 @@ export interface ConfigurationStatus { agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus }; alertRules: number; routingRules: { count: number; enabledCount: number; locked: boolean }; + suppressionRules: { total: number; enabledCount: number }; }; automation: { autoHeal: { total: number; enabled: number }; @@ -29,6 +30,7 @@ export interface ConfigurationStatus { mfaEnabled: boolean | null; ssoEnabled: boolean; ssoProvider: string | null; + trivyInstalled: boolean; scanPolicies: { total: number; enabled: number; locked: boolean }; }; thresholds: { diff --git a/frontend/src/components/fleet/FleetMasthead.tsx b/frontend/src/components/fleet/FleetMasthead.tsx index c3eb53ec..ac819b50 100644 --- a/frontend/src/components/fleet/FleetMasthead.tsx +++ b/frontend/src/components/fleet/FleetMasthead.tsx @@ -42,19 +42,19 @@ const healthConfig: Record You can reclaim - + {formatBytes(bytes)} {composition ? ( diff --git a/frontend/src/components/security/ImagesTab.tsx b/frontend/src/components/security/ImagesTab.tsx index bc056a7d..fc94966e 100644 --- a/frontend/src/components/security/ImagesTab.tsx +++ b/frontend/src/components/security/ImagesTab.tsx @@ -7,6 +7,7 @@ import { Combobox } from '@/components/ui/combobox'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { SeverityBadge } from '@/components/ui/SeverityBadge'; import { getSeverityKey, type SeverityKey, type ImageFilterValue } from '@/lib/severityStyles'; import { formatTimeAgo } from '@/lib/relativeTime'; @@ -255,20 +256,26 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann {canScan && ( - - - + + + + + + + + Scan image + + onScan(s.image_ref, ['vuln'])}> Scan (vulnerabilities) diff --git a/frontend/src/components/security/SecurityCharts.tsx b/frontend/src/components/security/SecurityCharts.tsx index 229625c6..faf30f1d 100644 --- a/frontend/src/components/security/SecurityCharts.tsx +++ b/frontend/src/components/security/SecurityCharts.tsx @@ -8,6 +8,7 @@ import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } f import { Button } from '@/components/ui/button'; import { useChartStyle, type ChartStyle } from '@/hooks/use-theme'; import { cn } from '@/lib/utils'; +import { TooltipProvider, Tooltip as RadixTooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import type { SecurityRiskTrendPoint, SecurityOverview, ExploitIntelFinding } from '@/types/security'; // Severity colours resolve through the --sev-* tokens, which the appearance @@ -260,7 +261,14 @@ export function TopExploitRiskList({ {f.epss_score !== null ? ( {Math.round(f.epss_score * 100)}% ) : ( - n/a + + + + n/a + + Exploitability unrated; treated as potentially automatable + + )} diff --git a/frontend/src/components/settings/CloudBackupSection.tsx b/frontend/src/components/settings/CloudBackupSection.tsx index a2be4d42..90f2cb65 100644 --- a/frontend/src/components/settings/CloudBackupSection.tsx +++ b/frontend/src/components/settings/CloudBackupSection.tsx @@ -7,6 +7,7 @@ import { Combobox } from '@/components/ui/combobox'; import { TogglePill } from '@/components/ui/toggle-pill'; import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { apiFetch } from '@/lib/api'; import { formatBytes } from '@/lib/utils'; import { useLicense } from '@/context/LicenseContext'; @@ -496,41 +497,53 @@ export function CloudBackupSection() {
- - + + + + + + Download + + + + + + + + Delete + +
))} diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index 87e6f423..80c42cd5 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { springs } from '@/lib/motion'; import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { CapabilityGate } from '@/components/CapabilityGate'; @@ -297,7 +298,7 @@ export function NotificationRoutingSection() { ); return ( - +
{ resetForm(); setShowForm(true); }}> @@ -432,7 +433,7 @@ export function NotificationRoutingSection() { setFormChannelType(v as 'discord' | 'slack' | 'webhook')}> - + Discord @@ -528,31 +529,50 @@ export function NotificationRoutingSection() { onChange={() => handleToggleEnabled(route)} className="scale-75" /> - - - + + + + + + Send test notification + + + + + + + + Edit + + + + + + + + Delete + +
diff --git a/frontend/src/components/settings/NotificationsSection.tsx b/frontend/src/components/settings/NotificationsSection.tsx index c3fa9fb0..de1ba118 100644 --- a/frontend/src/components/settings/NotificationsSection.tsx +++ b/frontend/src/components/settings/NotificationsSection.tsx @@ -157,7 +157,7 @@ export function NotificationsSection() {
setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full"> - + Discord diff --git a/frontend/src/components/settings/SuppressionsPanel.tsx b/frontend/src/components/settings/SuppressionsPanel.tsx index a7620369..a5c9f6c1 100644 --- a/frontend/src/components/settings/SuppressionsPanel.tsx +++ b/frontend/src/components/settings/SuppressionsPanel.tsx @@ -8,6 +8,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal'; import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2, Download } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { apiFetch } from '@/lib/api'; import { FleetTabHeading } from '@/components/fleet/FleetEmptyState'; import type { CveSuppression } from '@/types/security'; @@ -290,24 +291,36 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
- - + + + + + + Edit suppression + + + + + + + + Remove suppression + +
)} diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index ae65890f..62100dd5 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -7,6 +7,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Combobox } from '@/components/ui/combobox'; import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { apiFetch } from '@/lib/api'; import { useAuth, type UserRole } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; @@ -454,14 +455,20 @@ export function UsersSection() { {u.mfaEnabled && ( - + + + + + + Reset 2FA + + )} - - + + + + + + Move up + + + + + + + + Move down + + + + + + + + Remove + + )}
diff --git a/frontend/src/components/stack/GitSourceDiffDialog.tsx b/frontend/src/components/stack/GitSourceDiffDialog.tsx index cc40cc23..3b4bb819 100644 --- a/frontend/src/components/stack/GitSourceDiffDialog.tsx +++ b/frontend/src/components/stack/GitSourceDiffDialog.tsx @@ -101,7 +101,7 @@ export function GitSourceDiffDialog({ {envAvailable && ( setDiffTab(v as 'compose' | 'env')}> - + Compose diff --git a/frontend/src/components/stack/PreflightPanel.tsx b/frontend/src/components/stack/PreflightPanel.tsx index c61bd83c..b292e825 100644 --- a/frontend/src/components/stack/PreflightPanel.tsx +++ b/frontend/src/components/stack/PreflightPanel.tsx @@ -6,6 +6,7 @@ import { import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui/toast-store'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { formatTimeAgo } from '@/lib/relativeTime'; import { useNodes } from '@/context/NodeContext'; import { usePreflightDismiss } from '@/hooks/usePreflightDismiss'; @@ -387,16 +388,22 @@ export default function PreflightPanel({ stackName, canEdit = false }: { stackNa {summary && SummaryIcon && !dismissed && (
{hasActiveFindings && ( - + + + + + + Dismiss until findings change + + )}
diff --git a/frontend/src/components/ui/GitHubIcon.tsx b/frontend/src/components/ui/GitHubIcon.tsx new file mode 100644 index 00000000..35c0a06c --- /dev/null +++ b/frontend/src/components/ui/GitHubIcon.tsx @@ -0,0 +1,20 @@ +interface GitHubIconProps { + className?: string; + strokeWidth?: number; +} + +export function GitHubIcon({ className }: GitHubIconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/ui/MastheadRail.tsx b/frontend/src/components/ui/MastheadRail.tsx index f66e8622..50e233ba 100644 --- a/frontend/src/components/ui/MastheadRail.tsx +++ b/frontend/src/components/ui/MastheadRail.tsx @@ -14,9 +14,9 @@ export function MastheadRail({ variant, className }: MastheadRailProps) { className={cn('absolute inset-y-0 left-0 w-[3px] overflow-hidden', className)} > {variant === 'shimmer' ? ( -
+
) : ( -
+
)}
); diff --git a/frontend/src/components/ui/PageMasthead.tsx b/frontend/src/components/ui/PageMasthead.tsx index 141772c8..501f4a6e 100644 --- a/frontend/src/components/ui/PageMasthead.tsx +++ b/frontend/src/components/ui/PageMasthead.tsx @@ -36,22 +36,22 @@ const toneConfig: Record = { live: { - railClass: 'bg-brand', + railClass: 'bg-brand/70', stateTextClass: 'text-stat-value', tintClass: 'from-brand/[0.06] via-transparent to-transparent', }, idle: { - railClass: 'bg-stat-subtitle', + railClass: 'bg-stat-subtitle/70', stateTextClass: 'text-stat-title', tintClass: 'from-transparent via-transparent to-transparent', }, warn: { - railClass: 'bg-warning', + railClass: 'bg-warning/70', stateTextClass: 'text-warning', tintClass: 'from-warning/[0.06] via-transparent to-transparent', }, error: { - railClass: 'bg-destructive', + railClass: 'bg-destructive/70', stateTextClass: 'text-destructive', tintClass: 'from-destructive/[0.06] via-transparent to-transparent', }, diff --git a/frontend/src/components/ui/ScrollableTabRow.tsx b/frontend/src/components/ui/ScrollableTabRow.tsx index 4ed0694a..bc56bf09 100644 --- a/frontend/src/components/ui/ScrollableTabRow.tsx +++ b/frontend/src/components/ui/ScrollableTabRow.tsx @@ -98,7 +98,7 @@ export function ScrollableTabRow({ children, surface = 'card', className, wrappe onClick={() => scrollBy(1)} className={cn('absolute inset-y-0 right-0 flex w-7 items-center justify-end bg-gradient-to-l text-stat-subtitle transition-colors hover:text-brand', fade.right)} > - + )}
diff --git a/frontend/src/components/ui/SeverityBadge.tsx b/frontend/src/components/ui/SeverityBadge.tsx index 3d667f30..0d21c398 100644 --- a/frontend/src/components/ui/SeverityBadge.tsx +++ b/frontend/src/components/ui/SeverityBadge.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { cn } from '@/lib/utils'; -import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { SEVERITY_BADGE_CLASSES, SEVERITY_DOT_CLASSES, getSeverityKey } from '@/lib/severityStyles'; import type { ScanSummary } from '@/types/security'; @@ -50,13 +50,10 @@ export function SeverityBadge({ summary, onClick, tooltip = true }: { summary: S if (!tooltip) return pill; return ( - - {pill} - -
- - -
+ + + {pill} +
Last scanned
{relative}
@@ -78,8 +75,8 @@ export function SeverityBadge({ summary, onClick, tooltip = true }: { summary: S
No findings
)}
-
-
- + + + ); } diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index a2a77a61..aac97805 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -32,7 +32,7 @@ const TabsTrigger = React.forwardRef< ->(({ className, sideOffset = 4, ...props }, ref) => ( - - ) { + return ( + - -)); -TooltipContent.displayName = 'TooltipContent'; + ) +} -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/frontend/src/hooks/useScanBannerDismiss.ts b/frontend/src/hooks/useScanBannerDismiss.ts new file mode 100644 index 00000000..d0494635 --- /dev/null +++ b/frontend/src/hooks/useScanBannerDismiss.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +// Bumped when a dismiss is written so sibling consumers re-read localStorage +// and agree, without a full page reload. +const DISMISS_EVENT = 'sencho:scan-banner-dismiss-changed'; + +const keyFor = (stackName: string, nodeId: number | undefined) => + `sencho.scanBannerDismissed.${stackName}.${nodeId ?? 'local'}`; + +/** Fingerprint encodes the scan outcome: any new scan run (different attemptedAt) + * or status change produces a new fingerprint, re-surfacing the banner. */ +function fingerprint(status: string | null, attemptedAt: number | undefined): string { + if (!status) return ''; + return `${status}:${attemptedAt ?? 0}`; +} + +/** + * Per-stack dismiss for the post-deploy scan warning banner, persisted in + * localStorage and keyed to a fingerprint of the scan run. Dismissal sticks + * across reloads for the same scan outcome, and clears automatically once a + * new scan runs (different attemptedAt) or the status changes. + */ +export function useScanBannerDismiss( + stackName: string, + nodeId: number | undefined, + scanStatus: { status: string | null; attemptedAt?: number } | null, +) { + const fp = useMemo( + () => fingerprint(scanStatus?.status ?? null, scanStatus?.attemptedAt), + [scanStatus?.status, scanStatus?.attemptedAt], + ); + const storageKey = keyFor(stackName, nodeId); + + const read = useCallback(() => { + try { return localStorage.getItem(storageKey); } catch { return null; } + }, [storageKey]); + + const [storedFp, setStoredFp] = useState(() => read()); + + useEffect(() => { + setStoredFp(read()); + const handler = () => setStoredFp(read()); + window.addEventListener(DISMISS_EVENT, handler); + window.addEventListener('storage', handler); + return () => { + window.removeEventListener(DISMISS_EVENT, handler); + window.removeEventListener('storage', handler); + }; + }, [read]); + + const dismissed = fp !== '' && storedFp === fp; + + const dismiss = useCallback(() => { + try { localStorage.setItem(storageKey, fp); } catch { /* ignore */ } + setStoredFp(fp); + window.dispatchEvent(new Event(DISMISS_EVENT)); + }, [storageKey, fp]); + + return { dismissed, dismiss }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 39a209a1..eb3f370f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -441,6 +441,9 @@ [data-effects="reduced"] { --card-bevel: none; } +[data-effects="reduced"] .animate-pulse { + animation: none; +} /* Heading utility: every operational title routes through this. Base sets only family + style (Signature parity); the Calm-only descendant rule lifts weight @@ -717,8 +720,8 @@ body { 100% { transform: translateY(200%); } } @keyframes masthead-rail-glow { - 0%, 100% { opacity: 0.55; box-shadow: inset 0 0 0 0 transparent; } - 50% { opacity: 1; box-shadow: inset 0 0 10px 0 oklch(1 0 0 / 0.12); } + 0%, 100% { opacity: 0.15; } + 50% { opacity: 0.55; } } /* ───────────────────────────────────────────────────────────── @@ -743,10 +746,10 @@ body { animation: shimmer 4.5s ease-in-out infinite alternate; } .masthead-rail-shimmer { - animation: shimmer 11s ease-in-out infinite alternate; + animation: shimmer 5s ease-in-out infinite alternate; } .masthead-rail-glow { - animation: masthead-rail-glow 5.5s ease-in-out infinite alternate; + animation: masthead-rail-glow 4s ease-in-out infinite alternate; } /* Stagger delay helpers */