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 */