mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: UI polish - tooltips, masthead, tabs, settings, and layout consistency (#1563)
* 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 commit209fbd2742. * 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 <p> elements with different fonts and sizes. * Revert "fix: standardize all tooltips to match sidebar RowTooltip design" This reverts commit9e1d97d39e. * 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 commit16843a81b5. * 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)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 && (
|
||||
<Card className={`rounded-xl border-muted bg-card ${safeContainers.length > 1 ? 'flex flex-col min-h-0 max-h-[42%]' : 'shrink-0'}`}>
|
||||
<Card className={`rounded-xl border-muted bg-card ${safeContainers.length > 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'}`}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -424,6 +441,8 @@ export function EditorView(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
containersExpanded={containersExpanded}
|
||||
onToggleContainersExpand={toggleContainersExpand}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</ScrollArea>
|
||||
@@ -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 ? (
|
||||
<div className="flex-1 min-h-[180px] flex flex-col">
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
setLogsMode={setLogsMode}
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
|
||||
onToggleLogsExpand={toggleLogsExpand}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -464,9 +484,9 @@ export function EditorView(props: EditorViewProps) {
|
||||
logsMode={logsMode}
|
||||
setLogsMode={setLogsMode}
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
|
||||
onToggleLogsExpand={toggleLogsExpand}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -477,7 +497,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
<div className="flex items-center gap-4">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env' | 'files')}>
|
||||
<TabsList>
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="compose">
|
||||
<TabsTrigger value="compose">compose.yaml</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
@@ -658,7 +678,6 @@ export function EditorView(props: EditorViewProps) {
|
||||
applying={loadingAction === 'update'}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
notifications={notifications}
|
||||
stackMuteActions={stackMuteActions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -248,7 +248,6 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
applying={loadingAction === 'update'}
|
||||
canEdit={canEditStack}
|
||||
notifications={notifications}
|
||||
stackMuteActions={stackMuteActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
<ContainersHealth
|
||||
safeContainers={[makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]}
|
||||
@@ -194,9 +198,9 @@ describe('density toggle and summary strip', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
<>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>digest <span className="text-foreground/90">{digest}</span></span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'}
|
||||
onClick={() => {
|
||||
const id = first.ImageID as string;
|
||||
void copyToClipboard(id).then(() => {
|
||||
setCopiedDigest(id);
|
||||
if (copiedDigestTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedDigestTimerRef.current);
|
||||
}
|
||||
copiedDigestTimerRef.current = window.setTimeout(() => {
|
||||
setCopiedDigest(prev => (prev === id ? null : prev));
|
||||
copiedDigestTimerRef.current = null;
|
||||
}, 1500);
|
||||
}).catch(() => { /* clipboard unavailable */ });
|
||||
}}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
{copiedDigest === first.ImageID ? (
|
||||
<Check className="h-3 w-3" strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'}
|
||||
onClick={() => {
|
||||
const id = first.ImageID as string;
|
||||
void copyToClipboard(id).then(() => {
|
||||
setCopiedDigest(id);
|
||||
if (copiedDigestTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedDigestTimerRef.current);
|
||||
}
|
||||
copiedDigestTimerRef.current = window.setTimeout(() => {
|
||||
setCopiedDigest(prev => (prev === id ? null : prev));
|
||||
copiedDigestTimerRef.current = null;
|
||||
}, 1500);
|
||||
}).catch(() => { /* clipboard unavailable */ });
|
||||
}}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
{copiedDigest === first.ImageID ? (
|
||||
<Check className="h-3 w-3" strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy digest</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
<ImageSourceMenu imageRef={first.Image} imageId={first.ImageID} />
|
||||
@@ -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<void>;
|
||||
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<string | null>(null);
|
||||
const copiedUrlTimerRef = useRef<number | null>(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({
|
||||
<div>
|
||||
{containerStatsError && safeContainers.length > 0 && (
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5"
|
||||
title={containerStatsError}
|
||||
>
|
||||
Stats unavailable
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5">
|
||||
Stats unavailable
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containerStatsError}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{safeContainers.length === 0 ? (
|
||||
@@ -393,25 +411,61 @@ export function ContainersHealth({
|
||||
{paused > 0 && <span className="text-warning/80">{paused} paused</span>}
|
||||
{unhealthy > 0 && <span className="text-destructive/80">{unhealthy} unhealthy</span>}
|
||||
</div>
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('compact')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'compact'}
|
||||
aria-label="Compact view"
|
||||
>
|
||||
<List className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('detailed')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'detailed'}
|
||||
aria-label="Detailed view"
|
||||
>
|
||||
<Layers className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('compact')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'compact'}
|
||||
aria-label="Compact view"
|
||||
>
|
||||
<List className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Compact view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('detailed')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'detailed'}
|
||||
aria-label="Detailed view"
|
||||
>
|
||||
<Layers className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Detailed view</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{onToggleContainersExpand && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleContainersExpand}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${containersExpanded ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={containersExpanded}
|
||||
aria-label={containersExpanded ? 'Collapse containers' : 'Expand containers'}
|
||||
>
|
||||
{containersExpanded
|
||||
? <Minimize2 className="h-3 w-3" strokeWidth={1.5} />
|
||||
: <Maximize2 className="h-3 w-3" strokeWidth={1.5} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{containersExpanded ? 'Collapse containers' : 'Expand containers'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -483,19 +537,25 @@ export function ContainersHealth({
|
||||
>
|
||||
{portLabel} <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copiedUrlId === container?.Id ? 'Copied' : 'Copy service URL'}
|
||||
title="Copy service URL"
|
||||
onClick={() => copyServiceUrl(container?.Id, serviceUrl)}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copiedUrlId === container?.Id ? 'Copied' : 'Copy service URL'}
|
||||
onClick={() => copyServiceUrl(container?.Id, serviceUrl)}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
{copiedUrlId === container?.Id ? (
|
||||
<Check className="h-3 w-3" strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy service URL</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
) : (
|
||||
<span>{portLabel}</span>
|
||||
@@ -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"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
onClick={() => openLogViewer(container?.Id, containerName)}
|
||||
disabled={!isActive}
|
||||
aria-label="View logs"
|
||||
>
|
||||
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
onClick={() => openBashModal(container?.Id, containerName)}
|
||||
onClick={() => openLogViewer(container?.Id, containerName)}
|
||||
disabled={!isActive}
|
||||
aria-label="Open bash shell"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
aria-label="View logs"
|
||||
>
|
||||
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View logs</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAdmin && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
onClick={() => openBashModal(container?.Id, containerName)}
|
||||
disabled={!isActive}
|
||||
aria-label="Open bash shell"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open bash shell</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{container.Service && (
|
||||
<DropdownMenu>
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
</Button>
|
||||
{isAdmin && cloudEnabled && !cloudSnapshotIds.has(snapshot.id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
title="Upload to cloud"
|
||||
disabled={uploadingId === snapshot.id}
|
||||
onClick={() => handleCloudUpload(snapshot.id)}
|
||||
>
|
||||
{uploadingId === snapshot.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<CloudUpload className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={uploadingId === snapshot.id}
|
||||
onClick={() => handleCloudUpload(snapshot.id)}
|
||||
>
|
||||
{uploadingId === snapshot.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<CloudUpload className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Upload to cloud</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Network, SlidersHorizontal,
|
||||
Send, KeyRound, ArrowLeftRight, Wrench, Workflow, Tag,
|
||||
} from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { FleetMasthead } from './fleet/FleetMasthead';
|
||||
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
|
||||
import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet';
|
||||
@@ -115,7 +116,7 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
{/* Flatten the list's own pill band so the tabs sit directly in
|
||||
the single full-width band, not a nested second band. */}
|
||||
<TabsList className="border-transparent bg-transparent max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="overview">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
@@ -176,29 +177,41 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => overview.fetchOverview(true)}
|
||||
disabled={refreshing}
|
||||
className="h-9 w-9 p-0"
|
||||
title="Refresh"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => overview.fetchOverview(true)}
|
||||
disabled={refreshing}
|
||||
className="h-9 w-9 p-0"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void exportDossier(); }}
|
||||
disabled={exporting}
|
||||
className="h-9 w-9 p-0"
|
||||
title="Export Dossier"
|
||||
aria-label="Export Dossier"
|
||||
>
|
||||
<FileDown className={`w-4 h-4 ${exporting ? 'animate-pulse' : ''}`} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void exportDossier(); }}
|
||||
disabled={exporting}
|
||||
className="h-9 w-9 p-0"
|
||||
aria-label="Export Dossier"
|
||||
>
|
||||
<FileDown className={`w-4 h-4 ${exporting ? 'animate-pulse' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Export Dossier</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={() => setSearchExpanded(true)}
|
||||
title="Search nodes or stacks"
|
||||
aria-label="Search nodes or stacks"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={() => setSearchExpanded(true)}
|
||||
aria-label="Search nodes or stacks"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Search nodes or stacks</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<div className="w-40">
|
||||
<Combobox
|
||||
@@ -130,15 +137,21 @@ export function OverviewToolbar({
|
||||
className="[&>button]:!bg-background"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={() => onPrefsChange({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
||||
title={prefs.sortDir === 'asc' ? 'Switch to descending' : 'Switch to ascending'}
|
||||
>
|
||||
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={() => onPrefsChange({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
||||
>
|
||||
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{prefs.sortDir === 'asc' ? 'Switch to descending' : 'Switch to ascending'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -256,31 +269,43 @@ export function OverviewToolbar({
|
||||
/>
|
||||
|
||||
{onCheckUpdates && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-2 shrink-0"
|
||||
onClick={onCheckUpdates}
|
||||
disabled={checkingUpdates}
|
||||
title="Check for node updates"
|
||||
aria-label="Check for node updates"
|
||||
>
|
||||
<RefreshCcwDot className={`w-4 h-4 ${checkingUpdates ? 'animate-spin' : ''}`} />
|
||||
Node Update
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-2 shrink-0"
|
||||
onClick={onCheckUpdates}
|
||||
disabled={checkingUpdates}
|
||||
aria-label="Check for node updates"
|
||||
>
|
||||
<RefreshCcwDot className={`w-4 h-4 ${checkingUpdates ? 'animate-spin' : ''}`} />
|
||||
Node Update
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Check for node updates</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{onAddNode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={onAddNode}
|
||||
title="Manage nodes"
|
||||
aria-label="Manage nodes"
|
||||
>
|
||||
<ChartNetwork className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={onAddNode}
|
||||
aria-label="Manage nodes"
|
||||
>
|
||||
<ChartNetwork className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Manage nodes</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Image source links"
|
||||
title="Image source links"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Image source links"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Image source links</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel className="font-mono text-[11px] font-normal text-stat-subtitle truncate">
|
||||
{trimmedRef}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-1 px-3 py-2.5 border-b bg-muted/10">
|
||||
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
|
||||
{options.map(({ key, label, count }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onChange(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-all duration-200',
|
||||
value === key
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-4 px-1 rounded-sm text-[10px] font-mono transition-colors duration-200',
|
||||
value === key ? 'bg-muted text-foreground' : 'text-stat-subtitle',
|
||||
)}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{options.map(({ key, label, count }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={value === key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5 gap-1.5"
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
{label}
|
||||
<span className="font-mono tabular-nums text-[10px] opacity-70">{count}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -183,9 +175,16 @@ function ManagedBadge({ status, managedBy, onOpenStack }: {
|
||||
const inner = (<><span className="w-1.5 h-1.5 rounded-full bg-success shrink-0" />{managedBy}</>);
|
||||
if (onOpenStack && managedBy) {
|
||||
return (
|
||||
<button type="button" className={`${cls} hover:bg-success/15 transition-colors`} title={`Open stack ${managedBy}`} onClick={() => onOpenStack(managedBy)}>
|
||||
{inner}
|
||||
</button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className={`${cls} hover:bg-success/15 transition-colors`} onClick={() => onOpenStack(managedBy)}>
|
||||
{inner}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open stack {managedBy}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
return <span className={cls}>{inner}</span>;
|
||||
@@ -213,13 +212,17 @@ function ManagedBadge({ status, managedBy, onOpenStack }: {
|
||||
|
||||
function SenchoBadge() {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-brand/25 bg-brand/8 text-brand text-[10px] font-medium"
|
||||
title="Protected · running Sencho instance"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand shrink-0" />
|
||||
Sencho
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-brand/25 bg-brand/8 text-brand text-[10px] font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand shrink-0" />
|
||||
Sencho
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Protected · running Sencho instance</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -340,6 +343,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
const [volumeFilter, setVolumeFilter] = useState<ResourceFilter>('all');
|
||||
const [networkFilter, setNetworkFilter] = useState<ResourceFilter>('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 = {}
|
||||
<Tabs
|
||||
value={resourceTab}
|
||||
onValueChange={(v) => 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 ? (
|
||||
<MobileSubTabs
|
||||
@@ -820,19 +831,23 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 pt-3 pb-2 flex items-center justify-between gap-3">
|
||||
<TabsList className="grid grid-cols-4 w-full md:w-[680px] h-9 gap-1 p-0">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
|
||||
<TabsList className="border-transparent bg-transparent max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
{(['images', 'volumes', 'networks'] as const).map(tab => (
|
||||
<TabsHighlightItem key={tab} value={tab}>
|
||||
<TabsTrigger value={tab} className="capitalize text-xs">
|
||||
{tab}
|
||||
<TabsTrigger value={tab}>
|
||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">
|
||||
{tab === 'images' ? images.length : tab === 'volumes' ? volumes.length : networks.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
))}
|
||||
<TabsHighlightItem value="unmanaged">
|
||||
<TabsTrigger value="unmanaged" className="relative text-xs">
|
||||
<TabsTrigger value="unmanaged" className="relative">
|
||||
Unmanaged
|
||||
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">{totalOrphansCount}</span>
|
||||
{totalOrphansCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1 flex h-4 min-w-4 px-1 items-center justify-center rounded-full bg-warning text-[9px] text-warning-foreground font-medium animate-in zoom-in-75 duration-200">
|
||||
{totalOrphansCount}
|
||||
@@ -849,7 +864,16 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
|
||||
{/* Images */}
|
||||
<TabsContent value="images" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search images..."
|
||||
value={imageSearch}
|
||||
onChange={(e) => setImageSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<FilterToggle
|
||||
value={imageFilter}
|
||||
onChange={setImageFilter}
|
||||
@@ -859,24 +883,25 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
unmanaged: images.filter(i => i.managedStatus !== 'managed').length,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
{trivy.available && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5 mr-3 shrink-0"
|
||||
className="h-9 gap-2 shrink-0"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
|
||||
detail: { view: 'security', tab: 'history' },
|
||||
}));
|
||||
}}
|
||||
title="View completed vulnerability scans and compare them"
|
||||
aria-label="Open scan history"
|
||||
>
|
||||
<History className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
<History className="w-4 h-4" strokeWidth={1.5} />
|
||||
Scan history
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
@@ -917,33 +942,45 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setInspectImageId(img.Id)}
|
||||
title="Inspect image"
|
||||
aria-label={`Inspect ${img.RepoTags?.[0] || 'image'}`}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={scanningImageRef === img.RepoTags[0]}
|
||||
title="Scan for vulnerabilities"
|
||||
onClick={() => setInspectImageId(img.Id)}
|
||||
aria-label={`Inspect ${img.RepoTags?.[0] || 'image'}`}
|
||||
>
|
||||
{scanningImageRef === img.RepoTags[0] ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
<Eye className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Inspect image</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
||||
<DropdownMenu>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={scanningImageRef === img.RepoTags[0]}
|
||||
>
|
||||
{scanningImageRef === img.RepoTags[0] ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Scan for vulnerabilities</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleScanImage(img.RepoTags![0], { scanners: ['vuln'] })}
|
||||
@@ -958,16 +995,26 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
||||
disabled={img.isSencho}
|
||||
title={img.isSencho ? 'Protected · running Sencho instance' : undefined}
|
||||
onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>}
|
||||
{isAdmin && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={img.isSencho ? 'cursor-not-allowed' : undefined}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
||||
disabled={img.isSencho}
|
||||
onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{img.isSencho && <TooltipContent>Protected · running Sencho instance</TooltipContent>}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -975,19 +1022,32 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Volumes */}
|
||||
<TabsContent value="volumes" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
|
||||
<FilterToggle
|
||||
value={volumeFilter}
|
||||
onChange={setVolumeFilter}
|
||||
counts={{
|
||||
all: volumes.length,
|
||||
managed: volumes.filter(v => v.managedStatus === 'managed').length,
|
||||
unmanaged: volumes.filter(v => v.managedStatus !== 'managed').length,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search volumes..."
|
||||
value={volumeSearch}
|
||||
onChange={(e) => setVolumeSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<FilterToggle
|
||||
value={volumeFilter}
|
||||
onChange={setVolumeFilter}
|
||||
counts={{
|
||||
all: volumes.length,
|
||||
managed: volumes.filter(v => v.managedStatus === 'managed').length,
|
||||
unmanaged: volumes.filter(v => v.managedStatus !== 'managed').length,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
@@ -1020,27 +1080,43 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setBrowseVolume(vol.Name)}
|
||||
title="Browse volume contents"
|
||||
aria-label={`Browse ${vol.Name}`}
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setBrowseVolume(vol.Name)}
|
||||
aria-label={`Browse ${vol.Name}`}
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Browse volume contents</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={vol.isSencho ? 'cursor-not-allowed' : undefined}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
||||
disabled={vol.isSencho}
|
||||
onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{vol.isSencho && <TooltipContent>Protected · running Sencho instance</TooltipContent>}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
||||
disabled={vol.isSencho}
|
||||
title={vol.isSencho ? 'Protected · running Sencho instance' : undefined}
|
||||
onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1048,27 +1124,38 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Networks */}
|
||||
<TabsContent value="networks" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
{networkViewMode === 'list' ? (
|
||||
<FilterToggle
|
||||
value={networkFilter}
|
||||
onChange={setNetworkFilter}
|
||||
counts={{
|
||||
all: networks.length,
|
||||
managed: networks.filter(n => n.managedStatus === 'managed').length,
|
||||
unmanaged: networks.filter(n => n.managedStatus !== 'managed').length,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search networks..."
|
||||
value={networkSearch}
|
||||
onChange={(e) => setNetworkSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<FilterToggle
|
||||
value={networkFilter}
|
||||
onChange={setNetworkFilter}
|
||||
counts={{
|
||||
all: networks.length,
|
||||
managed: networks.filter(n => 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.
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 pr-3">
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setNetworkViewMode('list')}
|
||||
@@ -1125,6 +1212,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
</CapabilityGate>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
@@ -1173,16 +1261,26 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
>
|
||||
{inspectLoadingId === net.Id ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : <Eye className="w-3.5 h-3.5" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
||||
disabled={net.managedStatus === 'system' || net.isSencho}
|
||||
title={net.isSencho ? 'Protected · running Sencho instance' : undefined}
|
||||
onClick={() => setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>}
|
||||
{isAdmin && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={net.isSencho ? 'cursor-not-allowed' : undefined}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
||||
disabled={net.managedStatus === 'system' || net.isSencho}
|
||||
onClick={() => setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{net.isSencho && <TooltipContent>Protected · running Sencho instance</TooltipContent>}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1190,11 +1288,13 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Unmanaged Containers */}
|
||||
<TabsContent value="unmanaged" className="m-0 border-0 p-0 h-full flex flex-col animate-in fade-in-0 duration-200">
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b bg-muted/10 sticky top-0 z-10">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<input
|
||||
@@ -1275,6 +1375,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
@@ -9,8 +9,10 @@ import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download, CalendarClock, Table2 } from 'lucide-react';
|
||||
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download, CalendarClock, Table2, Copy } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { excludeLikelySenchoContainers } from '@/lib/senchoContainerFilter';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
@@ -537,26 +539,14 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<CardTitle>Scheduled Operations</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex items-center rounded-md border border-card-border bg-card p-0.5 shadow-btn-glow">
|
||||
<Button
|
||||
variant={view === 'timeline' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 gap-1.5"
|
||||
onClick={() => setView('timeline')}
|
||||
>
|
||||
<CalendarClock className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
<span className="text-xs">Timeline</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === 'table' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 gap-1.5"
|
||||
onClick={() => setView('table')}
|
||||
>
|
||||
<Table2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
<span className="text-xs">All tasks</span>
|
||||
</Button>
|
||||
</div>
|
||||
<SegmentedControl<'timeline' | 'table'>
|
||||
value={view}
|
||||
options={[
|
||||
{ value: 'timeline', label: 'Timeline', icon: CalendarClock },
|
||||
{ value: 'table', label: 'All tasks', icon: Table2 },
|
||||
]}
|
||||
onChange={(v) => setView(v)}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={fetchTasks} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
||||
Refresh
|
||||
@@ -797,18 +787,46 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRunNow(task)} title="Run now" disabled={runningTaskId === task.id}>
|
||||
<Play className={`w-4 h-4 ${runningTaskId === task.id ? 'animate-pulse' : ''}`} strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openRuns(task)} title="Execution history">
|
||||
<History className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(task)} title="Edit">
|
||||
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(task)} title="Delete" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRunNow(task)} disabled={runningTaskId === task.id} aria-label="Run now">
|
||||
<Play className={`w-4 h-4 ${runningTaskId === task.id ? 'animate-pulse' : ''}`} strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Run now</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={() => openRuns(task)} aria-label="Execution history">
|
||||
<History className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Execution history</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(task)} aria-label="Edit">
|
||||
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(task)} className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" aria-label="Delete">
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1135,8 +1153,32 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{duration}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate" title={run.output || run.error || ''}>
|
||||
{run.error || run.output || '-'}
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate" title={run.output || run.error || ''}>
|
||||
{run.error || run.output || '-'}
|
||||
</span>
|
||||
{(run.output || run.error) ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
copyToClipboard(run.output || run.error || '');
|
||||
toast.info('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3 h-3" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy details</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
import { deriveMasthead, SCANNER_DETECTIONS_NOTE } from './security/securityMasthead';
|
||||
import { springs } from '@/lib/motion';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
@@ -237,13 +238,16 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
<span>
|
||||
{overview.scannedImages} {overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner {overview.scanner.available ? 'ready' : 'not installed'}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex shrink-0 cursor-help text-stat-subtitle/70 hover:text-stat-subtitle"
|
||||
title={SCANNER_DETECTIONS_NOTE}
|
||||
aria-label={SCANNER_DETECTIONS_NOTE}
|
||||
>
|
||||
<Info className="h-3 w-3" strokeWidth={1.5} aria-hidden="true" />
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 cursor-help text-stat-subtitle/70 hover:text-stat-subtitle">
|
||||
<Info className="h-3 w-3" strokeWidth={1.5} aria-hidden="true" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[260px]">{SCANNER_DETECTIONS_NOTE}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
) : undefined;
|
||||
|
||||
@@ -400,7 +404,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
is flattened so the tabs sit directly in this single band. */}
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
|
||||
<TabsList className="border-transparent bg-transparent max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="overview">
|
||||
<TabsTrigger value="overview"><LayoutDashboard className="w-4 h-4 mr-1.5" />Overview</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
|
||||
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen, X } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { ScrollableTabRow } from './ui/ScrollableTabRow';
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
|
||||
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
|
||||
import { useScanBannerDismiss } from '@/hooks/useScanBannerDismiss';
|
||||
import { parseAnatomy, parseEnvKeys, formatGitSource, imageName, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy';
|
||||
import { buildServiceUrl } from '@/lib/serviceUrl';
|
||||
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
||||
@@ -19,8 +20,6 @@ import ComposeLabelsPanel from './stack/ComposeLabelsPanel';
|
||||
import StackNetworkingPanel from './stack/StackNetworkingPanel';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
import { ActivityMuteKebab } from '@/components/mute/MuteMenuItems';
|
||||
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
interface StackAnatomyPanelProps {
|
||||
stackName: string;
|
||||
@@ -35,7 +34,6 @@ interface StackAnatomyPanelProps {
|
||||
canEdit: boolean;
|
||||
applying?: boolean;
|
||||
notifications?: NotificationItem[];
|
||||
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
||||
}
|
||||
|
||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
@@ -82,7 +80,7 @@ interface EffectiveAnatomyFacts {
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[72px_1fr] gap-3 border-t border-muted py-2 first:border-t-0">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle pt-0.5">{label}</span>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-stat-subtitle pt-0.5">{label}</span>
|
||||
<div className="min-w-0 text-xs text-foreground/90">{children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -101,7 +99,6 @@ export default function StackAnatomyPanel({
|
||||
canEdit,
|
||||
applying = false,
|
||||
notifications,
|
||||
stackMuteActions,
|
||||
}: StackAnatomyPanelProps) {
|
||||
const anatomy = useMemo(() => parseAnatomy(content), [content]);
|
||||
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
|
||||
@@ -141,6 +138,8 @@ export default function StackAnatomyPanel({
|
||||
attemptedAt?: number;
|
||||
errorMessage?: string | null;
|
||||
} | null>(null);
|
||||
const { dismissed: scanBannerDismissed, dismiss: dismissScanBanner } =
|
||||
useScanBannerDismiss(stackName, activeNode?.id, scanStatus);
|
||||
|
||||
// Best-effort badge: read the last stored preflight severity to dot the tab.
|
||||
// Skipped when the active node does not advertise the capability.
|
||||
@@ -402,21 +401,21 @@ export default function StackAnatomyPanel({
|
||||
<div className="flex items-center justify-between border-b border-muted px-3 py-1.5 gap-2">
|
||||
<ScrollableTabRow surface="card" wrapperClassName="min-w-0 flex-1">
|
||||
<TabsList className="h-7 w-max gap-0.5 bg-transparent border-none p-0">
|
||||
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
||||
<TabsTrigger value="anatomy" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
<TabsTrigger value="drift" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
||||
{envInventoryEnabled && (
|
||||
<TabsTrigger value="environment" data-testid="environment-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Environment</TabsTrigger>
|
||||
<TabsTrigger value="environment" data-testid="environment-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Environment</TabsTrigger>
|
||||
)}
|
||||
{composeLabelsEnabled && (
|
||||
<TabsTrigger value="compose-labels" data-testid="compose-labels-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Compose Labels</TabsTrigger>
|
||||
<TabsTrigger value="compose-labels" data-testid="compose-labels-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Compose Labels</TabsTrigger>
|
||||
)}
|
||||
{networkingEnabled && (
|
||||
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Networking</TabsTrigger>
|
||||
<TabsTrigger value="networking" data-testid="networking-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Networking</TabsTrigger>
|
||||
)}
|
||||
{doctorEnabled && (
|
||||
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">
|
||||
<TabsTrigger value="doctor" data-testid="doctor-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Doctor
|
||||
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && !doctorDismissed && (
|
||||
@@ -429,7 +428,7 @@ export default function StackAnatomyPanel({
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{storageEnabled && (
|
||||
<TabsTrigger value="storage" data-testid="storage-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Storage</TabsTrigger>
|
||||
<TabsTrigger value="storage" data-testid="storage-tab" className="py-1 px-2.5 font-mono text-[11px] uppercase tracking-[0.18em]">Storage</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</ScrollableTabRow>
|
||||
@@ -439,7 +438,7 @@ export default function StackAnatomyPanel({
|
||||
type="button"
|
||||
data-testid="anatomy-files-btn"
|
||||
onClick={onOpenFiles}
|
||||
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
className="inline-flex items-center gap-1 font-mono text-xs uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" strokeWidth={1.5} />
|
||||
files
|
||||
@@ -449,13 +448,12 @@ export default function StackAnatomyPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditCompose}
|
||||
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
className="inline-flex items-center gap-1 font-mono text-xs uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<Pencil className="h-3 w-3" strokeWidth={1.5} />
|
||||
edit
|
||||
</button>
|
||||
)}
|
||||
{stackMuteActions && <ActivityMuteKebab actions={stackMuteActions} />}
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
|
||||
@@ -621,7 +619,7 @@ export default function StackAnatomyPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && (
|
||||
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && !scanBannerDismissed && (
|
||||
<div
|
||||
className="mx-3 my-2 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/[0.06] px-2 py-1.5 text-xs text-warning"
|
||||
role="status"
|
||||
@@ -634,6 +632,14 @@ export default function StackAnatomyPanel({
|
||||
{scanStatus.status === 'skipped' && 'Post-deploy scan did not run.'}
|
||||
{scanStatus.errorMessage ? ` ${scanStatus.errorMessage}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 ml-2 p-0.5 rounded hover:bg-warning/10 transition-colors"
|
||||
onClick={dismissScanBanner}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import { Download, RefreshCw, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLogChipColorMode } from '@/hooks/use-log-chip-color-mode';
|
||||
@@ -285,23 +286,36 @@ export default function StructuredLogViewer({ stackName, expanded, onToggleExpan
|
||||
))}
|
||||
<div className="mx-1 h-4 w-px bg-muted" />
|
||||
{onToggleExpand && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onToggleExpand}
|
||||
aria-label={expanded ? 'Collapse logs' : 'Expand logs'}
|
||||
title={expanded ? 'Collapse logs' : 'Expand logs'}
|
||||
>
|
||||
{expanded
|
||||
? <Minimize2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
: <Maximize2 className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onToggleExpand}
|
||||
aria-label={expanded ? 'Collapse logs' : 'Expand logs'}
|
||||
>
|
||||
{expanded
|
||||
? <Minimize2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
: <Maximize2 className="h-3.5 w-3.5" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{expanded ? 'Collapse logs' : 'Expand logs'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button type="button" size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={downloadLogs} aria-label="Download logs">
|
||||
<Download className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={downloadLogs} aria-label="Download logs">
|
||||
<Download className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download logs</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { GitHubIcon } from '@/components/ui/GitHubIcon';
|
||||
import {
|
||||
Settings,
|
||||
LogOut,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
User,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
MessageSquare,
|
||||
CreditCard,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
@@ -150,9 +150,9 @@ export function UserProfileDropdown({ onOpenSettings }: UserProfileDropdownProps
|
||||
onClick={closeMenu}
|
||||
/>
|
||||
<MenuRow
|
||||
icon={MessageSquare}
|
||||
label="Feedback"
|
||||
href="https://github.com/studio-saelix/sencho/issues"
|
||||
icon={GitHubIcon}
|
||||
label="Open New Issue"
|
||||
href="https://github.com/Studio-Saelix/sencho/issues/new/choose"
|
||||
external
|
||||
onClick={closeMenu}
|
||||
/>
|
||||
@@ -175,7 +175,7 @@ export function UserProfileDropdown({ onOpenSettings }: UserProfileDropdownProps
|
||||
}
|
||||
|
||||
interface MenuRowProps {
|
||||
icon: LucideIcon;
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('ScheduledOperationsView', () => {
|
||||
tasksFixture = [makeTask({ id: 7, name: 'nightly-prune' })];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByRole('radio', { name: /All tasks/ }));
|
||||
expect(await screen.findByText('nightly-prune')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('ScheduledOperationsView', () => {
|
||||
];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByRole('radio', { name: /All tasks/ }));
|
||||
await screen.findByText('recurring-task');
|
||||
|
||||
// A single One-shot chip, scoped to the one-shot row.
|
||||
@@ -107,7 +107,7 @@ describe('ScheduledOperationsView', () => {
|
||||
tasksFixture = [makeTask({ id: 1, name: 'failed-one-shot', delete_after_run: 1, last_status: 'failure' })];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByRole('radio', { name: /All tasks/ }));
|
||||
const chip = await screen.findByText('One-shot');
|
||||
expect(chip).toHaveAttribute('title', expect.stringContaining('kept after a failed run'));
|
||||
});
|
||||
@@ -136,8 +136,8 @@ describe('ScheduledOperationsView', () => {
|
||||
})];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByTitle('Edit'));
|
||||
await userEvent.click(await screen.findByRole('radio', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
await waitFor(() => expect(screen.getAllByRole('combobox')[2]).toHaveTextContent('web'));
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('ScheduledOperationsView', () => {
|
||||
const onClearFilter = vi.fn();
|
||||
render(<ScheduledOperationsView filterNodeId={2} onClearFilter={onClearFilter} />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByRole('radio', { name: /All tasks/ }));
|
||||
expect(await screen.findByText('edge-task')).toBeInTheDocument();
|
||||
expect(screen.queryByText('hub-task')).not.toBeInTheDocument();
|
||||
|
||||
@@ -583,8 +583,8 @@ describe('ScheduledOperationsView', () => {
|
||||
})];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByTitle('Edit'));
|
||||
await userEvent.click(await screen.findByRole('radio', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Create Fleet Snapshot' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
@@ -719,8 +719,8 @@ describe('ScheduledOperationsView', () => {
|
||||
tasksFixture = [makeTask({ id: 5, name: 'daily-prune', cron_expression: '0 3 * * *', delete_after_run: 0 })];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByTitle('Edit'));
|
||||
await userEvent.click(await screen.findByRole('radio', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Simple' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('radio', { name: 'Daily' })).toHaveAttribute('aria-checked', 'true');
|
||||
@@ -737,8 +737,8 @@ describe('ScheduledOperationsView', () => {
|
||||
})];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByTitle('Edit'));
|
||||
await userEvent.click(await screen.findByRole('radio', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -754,8 +754,8 @@ describe('ScheduledOperationsView', () => {
|
||||
tasksFixture = [makeTask({ id: 6, name: 'every-15', cron_expression: '*/15 * * * *' })];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByTitle('Edit'));
|
||||
await userEvent.click(await screen.findByRole('radio', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Advanced' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByDisplayValue('*/15 * * * *')).toBeInTheDocument();
|
||||
|
||||
@@ -16,7 +16,7 @@ export function BlueprintEmptyState({ onCreate, canCreate }: BlueprintEmptyState
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.2em]">Deployments · Blueprints</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-serif text-2xl [font-style:var(--heading-style)] leading-tight tracking-[-0.01em] text-stat-value">
|
||||
<h3 className="font-heading text-2xl leading-tight tracking-[-0.01em] text-stat-value">
|
||||
Declare once. Distribute everywhere.
|
||||
</h3>
|
||||
<p className="font-sans text-sm leading-relaxed text-stat-subtitle">
|
||||
@@ -47,7 +47,7 @@ function Step({ kicker, title, copy }: { kicker: string; title: string; copy: st
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-brand">{kicker}</span>
|
||||
<span className="font-serif [font-style:var(--heading-style)] text-base text-stat-value">{title}</span>
|
||||
<span className="font-heading text-base text-stat-value">{title}</span>
|
||||
<span className="text-xs text-stat-subtitle leading-relaxed">{copy}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<span className="inline-flex items-center rounded-sm border border-success/30 bg-success/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-success">
|
||||
ON
|
||||
{lower === 'installed' ? 'INSTALLED' : 'ON'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (lower === 'off' || lower === 'disabled') {
|
||||
if (lower === 'off' || lower === 'disabled' || lower === 'not installed') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-card-border bg-card px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-stat-subtitle">
|
||||
OFF
|
||||
{lower === 'not installed' ? 'NOT INSTALLED' : 'OFF'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-0">
|
||||
<SectionHeader icon={Bell} label="Notifications" />
|
||||
<Row label="Notification agents" value={agentSummary} onClick={open('notifications')} />
|
||||
<Row label="Channels" value={agentSummary} onClick={open('notifications')} />
|
||||
<Row
|
||||
label="Alert rules"
|
||||
value={formatCount(notifications.alertRules, 'rule')}
|
||||
@@ -149,11 +149,16 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
||||
/>
|
||||
{!notifications.routingRules.locked && (
|
||||
<Row
|
||||
label="Notification routing"
|
||||
label="Routing"
|
||||
value={formatCount(notifications.routingRules.enabledCount, 'route')}
|
||||
onClick={open('notification-routing')}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Mute Rules"
|
||||
value={formatCount(notifications.suppressionRules.enabledCount, 'rule')}
|
||||
onClick={open('notification-suppression')}
|
||||
/>
|
||||
|
||||
<SectionHeader icon={Zap} label="Automation" />
|
||||
<Row
|
||||
@@ -188,9 +193,16 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
||||
onClick={open('account')}
|
||||
/>
|
||||
<Row label="SSO" value={ssoLabel} onClick={open('sso')} />
|
||||
<Row
|
||||
label="Trivy installed"
|
||||
value={security.trivyInstalled ? 'Installed' : 'Not installed'}
|
||||
onClick={() => window.dispatchEvent(
|
||||
new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security', tab: 'scanner' } }),
|
||||
)}
|
||||
/>
|
||||
{!security.scanPolicies.locked && (
|
||||
<Row
|
||||
label="Vulnerability scanning"
|
||||
label="Scan policies"
|
||||
value={formatCount(security.scanPolicies.enabled, 'policy')}
|
||||
onClick={() => window.dispatchEvent(
|
||||
new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security', tab: 'policies' } }),
|
||||
|
||||
@@ -20,19 +20,19 @@ const healthConfig: Record<HealthLevel, { label: string; textClass: string; rail
|
||||
healthy: {
|
||||
label: 'Healthy',
|
||||
textClass: 'text-stat-value',
|
||||
railClass: 'bg-brand',
|
||||
railClass: 'bg-brand/70',
|
||||
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
degraded: {
|
||||
label: 'Degraded',
|
||||
textClass: 'text-warning',
|
||||
railClass: 'bg-warning',
|
||||
railClass: 'bg-warning/70',
|
||||
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
textClass: 'text-destructive',
|
||||
railClass: 'bg-destructive',
|
||||
railClass: 'bg-destructive/70',
|
||||
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): 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<ConfigurationStatusPayload> = {}): 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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -42,19 +42,19 @@ const healthConfig: Record<FleetHealth, { label: string; textClass: string; rail
|
||||
healthy: {
|
||||
label: 'The fleet',
|
||||
textClass: 'text-stat-value',
|
||||
railClass: 'bg-brand',
|
||||
railClass: 'bg-brand/70',
|
||||
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
degraded: {
|
||||
label: 'The fleet',
|
||||
textClass: 'text-warning',
|
||||
railClass: 'bg-warning',
|
||||
railClass: 'bg-warning/70',
|
||||
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
critical: {
|
||||
label: 'The fleet',
|
||||
textClass: 'text-destructive',
|
||||
railClass: 'bg-destructive',
|
||||
railClass: 'bg-destructive/70',
|
||||
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ReclaimHero({
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-warning">
|
||||
You can reclaim
|
||||
</span>
|
||||
<span className="font-display italic text-4xl leading-none tracking-tight text-stat-value">
|
||||
<span className="font-heading text-4xl leading-none tracking-tight text-stat-value">
|
||||
{formatBytes(bytes)}
|
||||
</span>
|
||||
{composition ? (
|
||||
|
||||
@@ -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 && (
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={scanningRef === s.image_ref}
|
||||
title="Scan image"
|
||||
aria-label={`Scan ${s.image_ref}`}
|
||||
>
|
||||
{scanningRef === s.image_ref
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
: <ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={scanningRef === s.image_ref}
|
||||
aria-label={`Scan ${s.image_ref}`}
|
||||
>
|
||||
{scanningRef === s.image_ref
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
: <ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Scan image</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onScan(s.image_ref, ['vuln'])}>
|
||||
Scan (vulnerabilities)
|
||||
|
||||
@@ -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 ? (
|
||||
<span className="text-warning">{Math.round(f.epss_score * 100)}%</span>
|
||||
) : (
|
||||
<span className="text-stat-subtitle/70" title="Exploitability unrated; treated as potentially automatable">n/a</span>
|
||||
<TooltipProvider>
|
||||
<RadixTooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-stat-subtitle/70 cursor-help">n/a</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Exploitability unrated; treated as potentially automatable</TooltipContent>
|
||||
</RadixTooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-right font-mono text-[11px] tabular-nums text-stat-subtitle">
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={async () => {
|
||||
const encoded = btoa(s.objectKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
try {
|
||||
const res = await apiFetch(`/cloud-backup/object/${encoded}/download`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || `Download failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = s.objectKey.split('/').pop() || 'snapshot.tar.gz';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Download failed.');
|
||||
}
|
||||
}}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setDeleteKey(s.objectKey)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={async () => {
|
||||
const encoded = btoa(s.objectKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
try {
|
||||
const res = await apiFetch(`/cloud-backup/object/${encoded}/download`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || `Download failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = s.objectKey.split('/').pop() || 'snapshot.tar.gz';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Download failed.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setDeleteKey(s.objectKey)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
|
||||
<CapabilityGate capability="notification-routing" featureName="Routing">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
|
||||
@@ -432,7 +433,7 @@ export function NotificationRoutingSection() {
|
||||
<Label>Channel</Label>
|
||||
<Tabs value={formChannelType} onValueChange={(v) => setFormChannelType(v as 'discord' | 'slack' | 'webhook')}>
|
||||
<TabsList className="w-full grid grid-cols-3">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="discord">
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
@@ -528,31 +529,50 @@ export function NotificationRoutingSection() {
|
||||
onChange={() => handleToggleEnabled(route)}
|
||||
className="scale-75"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTest(route.id)}
|
||||
disabled={testingId === route.id}
|
||||
title="Send test notification"
|
||||
>
|
||||
{testingId === route.id ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-4 h-4" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(route)} title="Edit">
|
||||
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
title="Delete"
|
||||
onClick={() => setDeleteRouteId(route.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTest(route.id)}
|
||||
disabled={testingId === route.id}
|
||||
>
|
||||
{testingId === route.id ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-4 h-4" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Send test notification</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(route)}>
|
||||
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setDeleteRouteId(route.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
|
||||
@@ -157,7 +157,7 @@ export function NotificationsSection() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full">
|
||||
<TabsList className="w-full mb-4 grid grid-cols-3">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="discord">
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-stat-subtitle hover:text-stat-value"
|
||||
onClick={() => openEdit(row)}
|
||||
title="Edit suppression"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setDeleteRow(row)}
|
||||
title="Remove suppression"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-stat-subtitle hover:text-stat-value"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit suppression</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setDeleteRow(row)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove suppression</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -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() {
|
||||
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
{u.mfaEnabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Reset 2FA"
|
||||
onClick={() => setResetMfaTarget(u)}
|
||||
>
|
||||
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResetMfaTarget(u)}
|
||||
>
|
||||
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset 2FA</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('settings registry', () => {
|
||||
it('applies the renamed section labels', () => {
|
||||
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
|
||||
expect(byId.get('notifications')?.label).toBe('Channels');
|
||||
expect(byId.get('notification-routing')?.label).toBe('Notification Routing');
|
||||
expect(byId.get('notification-routing')?.label).toBe('Routing');
|
||||
expect(byId.get('notification-suppression')?.label).toBe('Mute Rules');
|
||||
});
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
{
|
||||
id: 'notification-routing',
|
||||
group: 'notifications',
|
||||
label: 'Notification Routing',
|
||||
label: 'Routing',
|
||||
description: 'Rules that steer alerts to the right channel based on severity or label.',
|
||||
keywords: ['rules', 'routing', 'channels', 'severity', 'labels'],
|
||||
tier: null,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||
import type { CheckStatus } from '@/types/imageUpdates';
|
||||
import { Cursor, CursorContainer, CursorFollow, CursorProvider } from '@/components/animate-ui/primitives/animate/cursor';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import type { Label } from '@/components/label-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { sidebarRowActive, sidebarRowBase, sidebarRowCheckboxSlot } from './sidebar-styles';
|
||||
import { statusText, statusColor } from './stack-status-utils';
|
||||
import type { StackRowStatus } from './stack-status-utils';
|
||||
@@ -34,15 +34,14 @@ interface StackRowProps {
|
||||
|
||||
function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
|
||||
return (
|
||||
<CursorProvider>
|
||||
<CursorContainer className="inline-flex items-center shrink-0">{trigger}</CursorContainer>
|
||||
<Cursor><div className="h-2 w-2 rounded-full bg-brand" /></Cursor>
|
||||
<CursorFollow side="bottom" sideOffset={4} align="center" transition={{ stiffness: 400, damping: 40, bounce: 0 }}>
|
||||
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
|
||||
<span className="font-mono text-xs tabular-nums text-stat-value">{label}</span>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
</CursorProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4} align="center">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,24 +117,12 @@ export function StackRow(props: StackRowProps) {
|
||||
/>
|
||||
) : checkStatus === 'failed' ? (
|
||||
<RowTooltip
|
||||
trigger={(
|
||||
<AlertCircle
|
||||
className="w-3 h-3 text-muted-foreground/70"
|
||||
strokeWidth={1.5}
|
||||
data-testid="stack-trailing-check-failed"
|
||||
/>
|
||||
)}
|
||||
trigger={<span data-testid="stack-trailing-check-failed"><AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} /></span>}
|
||||
label={lastError ? `Update check failed: ${lastError}` : 'Update check failed'}
|
||||
/>
|
||||
) : hasGitPending ? (
|
||||
<RowTooltip
|
||||
trigger={(
|
||||
<GitBranch
|
||||
className="w-3 h-3 text-brand"
|
||||
strokeWidth={1.5}
|
||||
data-testid="stack-trailing-git-pending"
|
||||
/>
|
||||
)}
|
||||
trigger={<span data-testid="stack-trailing-git-pending"><GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} /></span>}
|
||||
label="Git source update pending"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -43,17 +43,15 @@ describe('StackRow', () => {
|
||||
});
|
||||
|
||||
it('wraps the partial pill in a hover tooltip', () => {
|
||||
// jsdom does not mount the cursor-follow label, so assert the PT trigger is
|
||||
// wrapped in the RowTooltip cursor-container; the visible "3/5 running"
|
||||
// tooltip text is verified in the Playwright drive.
|
||||
const { container } = render(<StackRow {...base({ status: 'partial', running: 3, total: 5 })} />);
|
||||
expect(screen.getByText('PT').closest('[data-slot="cursor-container"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-slot="cursor-container"]')).not.toBeNull();
|
||||
render(<StackRow {...base({ status: 'partial', running: 3, total: 5 })} />);
|
||||
const trigger = screen.getByText('PT');
|
||||
// Radix TooltipTrigger adds data-state to the wrapped element.
|
||||
expect(trigger.getAttribute('data-state')).toBe('closed');
|
||||
});
|
||||
|
||||
it('does not wrap a non-partial pill in a tooltip', () => {
|
||||
render(<StackRow {...base({ status: 'running' })} />);
|
||||
expect(screen.getByText('UP').closest('[data-slot="cursor-container"]')).toBeNull();
|
||||
expect(screen.getByText('UP').getAttribute('data-state')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders cyan rail only when active', () => {
|
||||
@@ -103,12 +101,12 @@ describe('StackRow', () => {
|
||||
});
|
||||
|
||||
// ── Image-update check status indicator ────────────────────────────────
|
||||
// status='running' renders the pill as plain text (no tooltip), so the only
|
||||
// cursor-container in these rows is the trailing update/check indicator.
|
||||
|
||||
it('shows a muted check-failed indicator when the last check failed and there is no update', () => {
|
||||
const { container } = render(<StackRow {...base({ status: 'running', hasUpdate: false, checkStatus: 'failed', lastError: 'Registry unreachable' })} />);
|
||||
expect(container.querySelector('[data-slot="cursor-container"]')).not.toBeNull();
|
||||
// The trailing slot renders a tooltip-wrapped AlertCircle icon (an SVG).
|
||||
const trailingSlot = container.querySelector('[data-state="closed"]');
|
||||
expect(trailingSlot).not.toBeNull();
|
||||
// It is not the update dot.
|
||||
expect(container.querySelector('.bg-update')).toBeNull();
|
||||
});
|
||||
@@ -120,7 +118,7 @@ describe('StackRow', () => {
|
||||
|
||||
it('shows no trailing indicator for a clean ok check with no update', () => {
|
||||
const { container } = render(<StackRow {...base({ status: 'running', hasUpdate: false, checkStatus: 'ok' })} />);
|
||||
expect(container.querySelector('[data-slot="cursor-container"]')).toBeNull();
|
||||
expect(container.querySelector('.lucide-alert-circle')).toBeNull();
|
||||
expect(container.querySelector('.bg-update')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Lock, Copy, Info, TriangleAlert, ShieldAlert, X, ChevronUp, ChevronDown
|
||||
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 { copyToClipboard } from '@/lib/clipboard';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -63,13 +64,19 @@ function ItemRow({ item }: { item: EnvInventoryItem }) {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[12px] font-medium text-foreground/90">{item.key}</span>
|
||||
{item.likelySecret && (
|
||||
<span
|
||||
data-testid="env-secret-badge"
|
||||
title="Likely a secret. Its value is never read or shown."
|
||||
className="inline-flex items-center gap-1 rounded border border-muted bg-card/40 px-1.5 py-0.5 font-mono text-[10px] text-stat-subtitle"
|
||||
>
|
||||
<Lock className="h-2.5 w-2.5" strokeWidth={1.5} /> secret
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid="env-secret-badge"
|
||||
className="inline-flex items-center gap-1 rounded border border-muted bg-card/40 px-1.5 py-0.5 font-mono text-[10px] text-stat-subtitle"
|
||||
>
|
||||
<Lock className="h-2.5 w-2.5" strokeWidth={1.5} /> secret
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Likely a secret. Its value is never read or shown.</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{item.required && (
|
||||
<span className="rounded border border-muted px-1.5 py-0.5 font-mono text-[10px] text-stat-subtitle">required</span>
|
||||
@@ -251,33 +258,51 @@ export default function EnvironmentPanel({ stackName }: { stackName: string }) {
|
||||
<span className="font-mono text-[12px] text-foreground/90 flex-1">{file}</span>
|
||||
{canEdit && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveProjectEnvFile(idx, -1)}
|
||||
disabled={idx === 0 || savingProjectEnv}
|
||||
className="p-0.5 text-stat-subtitle hover:text-brand disabled:opacity-30"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveProjectEnvFile(idx, 1)}
|
||||
disabled={idx === projectEnvFiles.length - 1 || savingProjectEnv}
|
||||
className="p-0.5 text-stat-subtitle hover:text-brand disabled:opacity-30"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeProjectEnvFile(idx)}
|
||||
disabled={savingProjectEnv}
|
||||
className="p-0.5 text-stat-subtitle hover:text-destructive disabled:opacity-30"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveProjectEnvFile(idx, -1)}
|
||||
disabled={idx === 0 || savingProjectEnv}
|
||||
className="p-0.5 text-stat-subtitle hover:text-brand disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Move up</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveProjectEnvFile(idx, 1)}
|
||||
disabled={idx === projectEnvFiles.length - 1 || savingProjectEnv}
|
||||
className="p-0.5 text-stat-subtitle hover:text-brand disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Move down</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeProjectEnvFile(idx)}
|
||||
disabled={savingProjectEnv}
|
||||
className="p-0.5 text-stat-subtitle hover:text-destructive disabled:opacity-30"
|
||||
>
|
||||
<X className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export function GitSourceDiffDialog({
|
||||
{envAvailable && (
|
||||
<Tabs value={diffTab} onValueChange={(v) => setDiffTab(v as 'compose' | 'env')}>
|
||||
<TabsList>
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="compose">
|
||||
<TabsTrigger value="compose">Compose</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
|
||||
@@ -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 && (
|
||||
<div data-testid="preflight-status" data-status={report.activeStatus} className={cn(CARD_CLASS, summary.tone, 'relative')}>
|
||||
{hasActiveFindings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
data-testid="preflight-dismiss-btn"
|
||||
aria-label="Dismiss until findings change"
|
||||
title="Dismiss until findings change"
|
||||
className="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded text-current/70 hover:bg-current/10 hover:text-current"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
data-testid="preflight-dismiss-btn"
|
||||
aria-label="Dismiss until findings change"
|
||||
className="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded text-current/70 hover:bg-current/10 hover:text-current"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Dismiss until findings change</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pr-6">
|
||||
<SummaryIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
interface GitHubIconProps {
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function GitHubIcon({ className }: GitHubIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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' ? (
|
||||
<div className="masthead-rail-shimmer absolute inset-x-0 h-1/3 bg-gradient-to-b from-transparent via-white/25 to-transparent" />
|
||||
<div className="masthead-rail-shimmer absolute inset-x-0 h-1/3 bg-gradient-to-b from-transparent via-white/50 to-transparent" />
|
||||
) : (
|
||||
<div className="masthead-rail-glow absolute inset-0" />
|
||||
<div className="masthead-rail-glow absolute inset-0 bg-white/20" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,22 +36,22 @@ const toneConfig: Record<MastheadTone, {
|
||||
tintClass: string;
|
||||
}> = {
|
||||
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',
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" strokeWidth={1.5} />
|
||||
<ChevronRight className="h-4 w-4 animate-pulse" strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<CursorProvider>
|
||||
<CursorContainer className="inline-flex">{pill}</CursorContainer>
|
||||
<Cursor>
|
||||
<div className="h-2 w-2 rounded-full bg-brand" />
|
||||
</Cursor>
|
||||
<CursorFollow side="bottom" align="end" sideOffset={8}>
|
||||
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-md px-3 py-2">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{pill}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" sideOffset={8}>
|
||||
<div className="font-mono tabular-nums text-xs space-y-1">
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Last scanned</div>
|
||||
<div className="text-stat-value">{relative}</div>
|
||||
@@ -78,8 +75,8 @@ export function SeverityBadge({ summary, onClick, tooltip = true }: { summary: S
|
||||
<div className="text-success">No findings</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
</CursorProvider>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<AnimateTabsTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-muted/50 hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow data-[state=active]:hover:bg-background',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:text-foreground data-[state=active]:text-brand',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,36 +1,56 @@
|
||||
'use client';
|
||||
"use client"
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Use Radix primitives directly to avoid animate-ui context dependencies
|
||||
// that can cause crashes in certain component tree configurations.
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-glass-border bg-popover px-3 py-1.5 text-xs text-popover-foreground backdrop-blur-[10px] backdrop-saturate-[1.15]',
|
||||
'origin-[--radix-tooltip-content-transform-origin]',
|
||||
'animate-in fade-in-0 zoom-in-95 duration-150',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = 'TooltipContent';
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border border-glass-border bg-popover px-3 py-1.5 text-xs text-popover-foreground backdrop-blur-[10px] backdrop-saturate-[1.15] origin-(--radix-tooltip-content-transform-origin) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-popover fill-popover" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
||||
@@ -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<string | null>(() => 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 };
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user