mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: make Reduced motion gate overlays, standardize the tab band, and polish fleet/snapshots (#1504)
* fix: address review feedback on motion, fleet band, and snapshots - Reduced motion now also skips the animate-ui overlay open/close animations (dialog, sheet, popover, dropdown-menu) via a shared useReducedTransition that zeroes the transition when reduced motion is active; MotionConfig alone only neutralized transform/layout, leaving the opacity/blur fade. - Fleet tab band: flatten the list's own pill band so the tabs sit in a single full-width band instead of a nested second band. - Fleet Overview: label the node-update button "Node Update"; the add-node button becomes a ChartNetwork icon that opens Settings > Nodes (outline style, matching the node-update button) instead of the add-node modal. - Blueprint deployments empty state: drive the serif headings from the theme heading-style token so Calm drops the italic. - Snapshots: move the per-stack Restore onto the stack header row (right side), matching the ghost action style. * feat: standardize the tab band on Security and add a tab hover state - Apply the Fleet full-width tab band to the Security page (single band, the list's pill background flattened so the tabs sit directly in it). - Add a hover highlight to tabs so the band reads as interactive, not flat; the active tab is unaffected. * fix: drive reduced-motion transitions from the app setting, not the OS query useReducedTransition called framer-motion's useReducedMotion(), which only reflects the OS prefers-reduced-motion media query and ignores our MotionConfig / appearance toggle, so dialogs kept animating with the setting on. Read the app's useReducedMotion selector instead. Verified: with the setting on, the dialog opacity snaps 0->1 in one frame; with it off, it ramps over the spring. * feat: standardize stack-detail body text to text-xs and add a log expand toggle - Bump the anatomy panel body text (rows, field values, warnings) and the structured log lines from 10/11/12px to text-xs (12px), leaving the uppercase kicker labels and the log level/source badges as-is. - Add an expand control next to the log download button that collapses the Command Center so the logs pane fills the left column; toggles back to restore.
This commit is contained in:
@@ -324,6 +324,15 @@ export function EditorView(props: EditorViewProps) {
|
||||
if (!editingCompose || activeTab !== 'files') setFilesFullscreen(false);
|
||||
}, [editingCompose, activeTab]);
|
||||
|
||||
// Expand the logs by collapsing the Command Center card so the logs pane
|
||||
// fills the left column. Toggled from the structured log viewer header.
|
||||
const [logsExpanded, setLogsExpanded] = useState(false);
|
||||
// The expand control lives only in the structured viewer; reset when the
|
||||
// raw terminal is selected so the Command Center can't be stranded hidden.
|
||||
useEffect(() => {
|
||||
if (logsMode === 'raw') setLogsExpanded(false);
|
||||
}, [logsMode]);
|
||||
|
||||
// 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.
|
||||
@@ -339,7 +348,9 @@ export function EditorView(props: EditorViewProps) {
|
||||
files fullscreen so the editor card fills the width. */}
|
||||
{!filesFullscreen && (
|
||||
<div className="flex flex-col gap-6 min-h-0">
|
||||
{/* Command Center Card (identity + health strip) */}
|
||||
{/* 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 shrink-0">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -404,9 +415,16 @@ export function EditorView(props: EditorViewProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Logs Section (fills remaining left-column height) */}
|
||||
<StackLogsSection stackName={stackName} logsMode={logsMode} setLogsMode={setLogsMode} />
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
setLogsMode={setLogsMode}
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ export function ViewRouter({
|
||||
<LazyView>
|
||||
<FleetView
|
||||
onNavigateToNode={onFleetNavigateToNode}
|
||||
onOpenSettingsSection={onOpenSettingsSection}
|
||||
fleetUpdatesIntent={fleetUpdatesIntent}
|
||||
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
|
||||
fleetTab={fleetTab}
|
||||
|
||||
@@ -555,10 +555,14 @@ export interface StackLogsSectionProps {
|
||||
stackName: string;
|
||||
logsMode: 'structured' | 'raw';
|
||||
setLogsMode: (mode: 'structured' | 'raw') => void;
|
||||
/** When set, the structured viewer shows an expand control that collapses
|
||||
* the Command Center to give the logs more vertical room. */
|
||||
logsExpanded?: boolean;
|
||||
onToggleLogsExpand?: () => void;
|
||||
}
|
||||
|
||||
// Logs pane: structured / raw-terminal toggle + the live viewer.
|
||||
export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogsSectionProps) {
|
||||
export function StackLogsSection({ stackName, logsMode, setLogsMode, logsExpanded, onToggleLogsExpand }: StackLogsSectionProps) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -568,7 +572,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogs
|
||||
type="button"
|
||||
onClick={() => setLogsMode('structured')}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
|
||||
'rounded px-2 py-0.5 font-mono text-xs uppercase tracking-wide transition-colors',
|
||||
logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -578,7 +582,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogs
|
||||
type="button"
|
||||
onClick={() => setLogsMode('raw')}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
|
||||
'rounded px-2 py-0.5 font-mono text-xs uppercase tracking-wide transition-colors',
|
||||
logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -588,7 +592,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogs
|
||||
</div>
|
||||
{logsMode === 'structured' ? (
|
||||
<ErrorBoundary>
|
||||
<StructuredLogViewer stackName={stackName} />
|
||||
<StructuredLogViewer stackName={stackName} expanded={logsExpanded} onToggleExpand={onToggleLogsExpand} />
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
|
||||
|
||||
@@ -590,22 +590,34 @@ export default function FleetSnapshots() {
|
||||
.find(s => s.nodeId === node.nodeId && s.stackName === stack.stackName)?.dossier;
|
||||
return (
|
||||
<div key={stackKey}>
|
||||
<button
|
||||
onClick={() => toggleStack(stackKey)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-left rounded-md hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{stackExpanded
|
||||
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-mono font-medium flex-1 truncate">
|
||||
{stack.stackName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] font-mono tabular-nums px-1.5 py-0 h-4 shrink-0">
|
||||
{stack.files.length} file{stack.files.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pr-3 rounded-md hover:bg-muted/50 transition-colors">
|
||||
<button
|
||||
onClick={() => toggleStack(stackKey)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-left"
|
||||
>
|
||||
{stackExpanded
|
||||
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-mono font-medium flex-1 truncate">
|
||||
{stack.stackName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] font-mono tabular-nums px-1.5 py-0 h-4 shrink-0">
|
||||
{stack.files.length} file{stack.files.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<RestoreButton
|
||||
nodeId={node.nodeId}
|
||||
nodeName={node.nodeName}
|
||||
stackName={stack.stackName}
|
||||
hasDossier={!!dossier}
|
||||
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
{stackExpanded && (
|
||||
@@ -650,20 +662,6 @@ export default function FleetSnapshots() {
|
||||
|
||||
{/* Preserved dossier notes (read-only) */}
|
||||
{dossier && <DossierBlock dossier={dossier} />}
|
||||
|
||||
{/* Restore (admin only), right-aligned to match the file action row */}
|
||||
{isAdmin && (
|
||||
<div className="flex justify-end px-3">
|
||||
<RestoreButton
|
||||
nodeId={node.nodeId}
|
||||
nodeName={node.nodeName}
|
||||
stackName={stack.stackName}
|
||||
hasDossier={!!dossier}
|
||||
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,9 +31,12 @@ import { SecretsTab } from './fleet/secrets/SecretsTab';
|
||||
import { DependencyMapTab } from './fleet/DependencyMapTab';
|
||||
import { useNodeActions } from './nodes/useNodeActions';
|
||||
import type { FleetTab } from '@/lib/events';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
|
||||
interface FleetViewProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
/** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
|
||||
onOpenSettingsSection?: (section: SectionId) => void;
|
||||
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
|
||||
onFleetUpdatesIntentConsumed?: () => void;
|
||||
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
|
||||
@@ -41,7 +44,7 @@ interface FleetViewProps {
|
||||
onFleetTabConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
|
||||
export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
@@ -81,7 +84,7 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
|
||||
|
||||
const { mastheadStats, lastSyncAt, loading, refreshing } = overview;
|
||||
|
||||
const { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({
|
||||
const { openEdit, openDelete, NodeActionModals } = useNodeActions({
|
||||
onNodeChange: () => { void overview.fetchOverview(true); },
|
||||
});
|
||||
|
||||
@@ -103,7 +106,9 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as FleetTab)}>
|
||||
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
|
||||
<TabsList className="max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
|
||||
{/* 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}>
|
||||
<TabsHighlightItem value="overview">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
@@ -216,7 +221,7 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
|
||||
onCordonChange={() => { void overview.fetchOverview(true); }}
|
||||
onEditNode={isAdmin ? openEdit : undefined}
|
||||
onDeleteNode={isAdmin ? openDelete : undefined}
|
||||
onAddNode={isAdmin ? openCreate : undefined}
|
||||
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
|
||||
onCheckUpdates={updateStatus.checkUpdates}
|
||||
checkingUpdates={updateStatus.checkingUpdates}
|
||||
topologyMode={topology.prefs.mode}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Search, ArrowUpDown, AlertTriangle, Play, Square,
|
||||
LayoutGrid, Network, SlidersHorizontal, Plus, RefreshCcwDot,
|
||||
LayoutGrid, Network, SlidersHorizontal, ChartNetwork, RefreshCcwDot,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -259,25 +259,27 @@ export function OverviewToolbar({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
className="h-9 gap-2 shrink-0"
|
||||
onClick={onCheckUpdates}
|
||||
disabled={checkingUpdates}
|
||||
title="Check for updates"
|
||||
aria-label="Check for updates"
|
||||
title="Check for node updates"
|
||||
aria-label="Check for node updates"
|
||||
>
|
||||
<RefreshCcwDot className={`w-4 h-4 ${checkingUpdates ? 'animate-spin' : ''}`} />
|
||||
Node Update
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onAddNode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={onAddNode}
|
||||
title="Add node"
|
||||
aria-label="Add node"
|
||||
title="Manage nodes"
|
||||
aria-label="Manage nodes"
|
||||
>
|
||||
<Plus className="w-4 h-4" strokeWidth={1.5} />
|
||||
<ChartNetwork className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -82,32 +82,32 @@ describe('OverviewToolbar', () => {
|
||||
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Add node button and fires onAddNode when provided', () => {
|
||||
it('renders the manage-nodes button and fires onAddNode when provided', () => {
|
||||
const onAddNode = vi.fn();
|
||||
render(<OverviewToolbar {...props({ onAddNode })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add node' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manage nodes' }));
|
||||
expect(onAddNode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('omits the Add node button when onAddNode is not provided', () => {
|
||||
it('omits the manage-nodes button when onAddNode is not provided', () => {
|
||||
render(<OverviewToolbar {...props()} />);
|
||||
expect(screen.queryByRole('button', { name: 'Add node' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Manage nodes' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Check Updates button and fires onCheckUpdates when provided', () => {
|
||||
const onCheckUpdates = vi.fn();
|
||||
render(<OverviewToolbar {...props({ onCheckUpdates })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Check for updates/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Check for node updates/ }));
|
||||
expect(onCheckUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('omits the Check Updates button when onCheckUpdates is not provided', () => {
|
||||
render(<OverviewToolbar {...props()} />);
|
||||
expect(screen.queryByRole('button', { name: /Check for updates/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Check for node updates/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the Check Updates button while a check is in flight', () => {
|
||||
render(<OverviewToolbar {...props({ onCheckUpdates: vi.fn(), checkingUpdates: true })} />);
|
||||
expect(screen.getByRole('button', { name: /Check for updates/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Check for node updates/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -396,8 +396,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
</PageMasthead>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SecurityTab)}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
{/* Standard full-width tab band (matches Fleet): the list's own pill band
|
||||
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}>
|
||||
<TabsHighlightItem value="overview">
|
||||
<TabsTrigger value="overview"><LayoutDashboard className="w-4 h-4 mr-1.5" />Overview</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
@@ -424,7 +427,8 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
<TabsTrigger value="scanner"><Wrench className="w-4 h-4 mr-1.5" />Scanner setup</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
</TabsList>
|
||||
</div>
|
||||
{tabPanels}
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ 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>
|
||||
<div className="min-w-0 text-[12px] text-foreground/90">{children}</div>
|
||||
<div className="min-w-0 text-xs text-foreground/90">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -359,18 +359,18 @@ 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-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
||||
<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>
|
||||
{envInventoryEnabled && (
|
||||
<TabsTrigger value="environment" data-testid="environment-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Environment</TabsTrigger>
|
||||
<TabsTrigger value="environment" data-testid="environment-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Environment</TabsTrigger>
|
||||
)}
|
||||
{networkingEnabled && (
|
||||
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Networking</TabsTrigger>
|
||||
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Networking</TabsTrigger>
|
||||
)}
|
||||
{doctorEnabled && (
|
||||
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">
|
||||
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Doctor
|
||||
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && !doctorDismissed && (
|
||||
@@ -383,7 +383,7 @@ export default function StackAnatomyPanel({
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{storageEnabled && (
|
||||
<TabsTrigger value="storage" data-testid="storage-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Storage</TabsTrigger>
|
||||
<TabsTrigger value="storage" data-testid="storage-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Storage</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</ScrollableTabRow>
|
||||
@@ -417,7 +417,7 @@ export default function StackAnatomyPanel({
|
||||
<TabsContent value="anatomy" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3">
|
||||
{!anatomy ? (
|
||||
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Unable to parse compose.yaml.</div>
|
||||
<div className="py-3 font-mono text-xs text-stat-subtitle">Unable to parse compose.yaml.</div>
|
||||
) : (
|
||||
<>
|
||||
<Row label="services">
|
||||
@@ -426,7 +426,7 @@ export default function StackAnatomyPanel({
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{anatomy.services.map(s => (
|
||||
<span key={s} className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">
|
||||
<span key={s} className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-xs text-brand">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
@@ -437,7 +437,7 @@ export default function StackAnatomyPanel({
|
||||
{Object.keys(anatomy.ports).length === 0 ? (
|
||||
<span className="text-stat-subtitle">none</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
||||
<div className="flex flex-col gap-0.5 font-mono text-xs">
|
||||
{Object.entries(anatomy.ports).flatMap(([svc, rows]) =>
|
||||
rows.map((r, i) => (
|
||||
<div key={`${svc}-${i}`} className="flex items-center gap-1.5">
|
||||
@@ -457,7 +457,7 @@ export default function StackAnatomyPanel({
|
||||
{Object.keys(anatomy.volumes).length === 0 ? (
|
||||
<span className="text-stat-subtitle">none</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
||||
<div className="flex flex-col gap-0.5 font-mono text-xs">
|
||||
{Object.entries(anatomy.volumes).flatMap(([svc, rows]) =>
|
||||
rows.map((r, i) => (
|
||||
<div key={`${svc}-${i}`} className="flex items-center gap-1.5 min-w-0">
|
||||
@@ -474,19 +474,19 @@ export default function StackAnatomyPanel({
|
||||
)}
|
||||
</Row>
|
||||
<Row label="restart">
|
||||
<span className="font-mono text-[11px]">{anatomy.restart ?? <span className="text-stat-subtitle">default</span>}</span>
|
||||
<span className="font-mono text-xs">{anatomy.restart ?? <span className="text-stat-subtitle">default</span>}</span>
|
||||
</Row>
|
||||
<Row label="env_file">
|
||||
{!firstEnvFile ? (
|
||||
<span className="text-stat-subtitle">none</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="font-mono text-[11px]">
|
||||
<div className="font-mono text-xs">
|
||||
<span className="text-foreground/90">{firstEnvFile}</span>
|
||||
<span className="text-stat-subtitle"> · {envVarCount} var{envVarCount === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
{missingVars.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 font-mono text-[11px] text-destructive">
|
||||
<div className="flex flex-wrap gap-1 font-mono text-xs text-destructive">
|
||||
{missingVars.map(v => (
|
||||
<span key={v}>{'${'}{v}{'}'} missing</span>
|
||||
))}
|
||||
@@ -496,14 +496,14 @@ export default function StackAnatomyPanel({
|
||||
)}
|
||||
</Row>
|
||||
<Row label="network">
|
||||
<span className="font-mono text-[11px]">{networkName} <span className="text-stat-subtitle">· bridge</span></span>
|
||||
<span className="font-mono text-xs">{networkName} <span className="text-stat-subtitle">· bridge</span></span>
|
||||
</Row>
|
||||
<Row label="source">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenGitSource}
|
||||
aria-label="Git Source"
|
||||
className="inline-flex items-center gap-1.5 font-mono text-[11px] text-left hover:text-brand transition-colors"
|
||||
className="inline-flex items-center gap-1.5 font-mono text-xs text-left hover:text-brand transition-colors"
|
||||
>
|
||||
<GitBranch className="h-3 w-3 shrink-0" strokeWidth={1.5} />
|
||||
{activeGitSource ? (
|
||||
@@ -522,7 +522,7 @@ export default function StackAnatomyPanel({
|
||||
<div data-testid="update-available-banner" className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-[11px] uppercase tracking-wide">
|
||||
<div className="font-mono text-xs uppercase tracking-wide">
|
||||
Update available
|
||||
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
|
||||
<span className="text-foreground">
|
||||
@@ -533,7 +533,7 @@ export default function StackAnatomyPanel({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-foreground/80 leading-relaxed">
|
||||
<div className="mt-1 font-mono text-xs text-foreground/80 leading-relaxed">
|
||||
{[
|
||||
bumpLabel,
|
||||
bannerLeadIn,
|
||||
@@ -562,7 +562,7 @@ export default function StackAnatomyPanel({
|
||||
)}
|
||||
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && (
|
||||
<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-[11px] text-warning"
|
||||
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"
|
||||
title={scanStatus.errorMessage ?? undefined}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import { Download, RefreshCw, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLogChipColorMode } from '@/hooks/use-log-chip-color-mode';
|
||||
import { hashLabel } from '@/lib/label-colors';
|
||||
|
||||
interface StructuredLogViewerProps {
|
||||
stackName: string;
|
||||
/** When set, renders an expand/collapse control next to the download button. */
|
||||
expanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
}
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'err';
|
||||
@@ -65,7 +68,7 @@ function formatTs(iso: string | null): string {
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export default function StructuredLogViewer({ stackName }: StructuredLogViewerProps) {
|
||||
export default function StructuredLogViewer({ stackName, expanded, onToggleExpand }: StructuredLogViewerProps) {
|
||||
const [rows, setRows] = useState<LogRow[]>([]);
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [following, setFollowing] = useState(true);
|
||||
@@ -281,6 +284,21 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
|
||||
</button>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
@@ -289,7 +307,7 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto font-mono text-[11px] leading-[1.5]"
|
||||
className="flex-1 min-h-0 overflow-y-auto font-mono text-xs leading-[1.5]"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-2 text-stat-subtitle">Waiting for log output…</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition';
|
||||
|
||||
type DialogContextType = {
|
||||
isOpen: boolean;
|
||||
@@ -73,6 +74,7 @@ function DialogOverlay({
|
||||
transition = { duration: 0.2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: DialogOverlayProps) {
|
||||
const resolvedTransition = useReducedTransition(transition);
|
||||
return (
|
||||
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
|
||||
<motion.div
|
||||
@@ -80,7 +82,7 @@ function DialogOverlay({
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
transition={resolvedTransition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Overlay>
|
||||
@@ -111,6 +113,7 @@ function DialogContent({
|
||||
from === 'bottom' || from === 'left' ? '20deg' : '-20deg';
|
||||
const isVertical = from === 'top' || from === 'bottom';
|
||||
const rotateAxis = isVertical ? 'rotateX' : 'rotateY';
|
||||
const resolvedTransition = useReducedTransition(transition);
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Content
|
||||
@@ -140,7 +143,7 @@ function DialogContent({
|
||||
filter: 'blur(4px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
transition={transition}
|
||||
transition={resolvedTransition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/animate-ui/primitives/effects/highlight';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition';
|
||||
import { useDataState } from '@/hooks/use-data-state';
|
||||
|
||||
type DropdownMenuContextType = {
|
||||
@@ -198,6 +199,7 @@ function DropdownMenuSubContent({
|
||||
...props
|
||||
}: DropdownMenuSubContentProps) {
|
||||
const { isOpen } = useDropdownMenuSub();
|
||||
const resolvedTransition = useReducedTransition(transition);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -226,7 +228,7 @@ function DropdownMenuSubContent({
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
transition={resolvedTransition}
|
||||
style={{ willChange: 'opacity, transform', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
@@ -295,6 +297,7 @@ function DropdownMenuContent({
|
||||
...props
|
||||
}: DropdownMenuContentProps) {
|
||||
const { isOpen } = useDropdownMenu();
|
||||
const resolvedTransition = useReducedTransition(transition);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -325,7 +328,7 @@ function DropdownMenuContent({
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
transition={resolvedTransition}
|
||||
style={{ willChange: 'opacity, transform', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition';
|
||||
|
||||
type PopoverContextType = {
|
||||
isOpen: boolean;
|
||||
@@ -91,6 +92,7 @@ function PopoverContent({
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: PopoverContentProps) {
|
||||
const resolvedTransition = useReducedTransition(transition);
|
||||
return (
|
||||
<PopoverPrimitive.Content
|
||||
asChild
|
||||
@@ -118,7 +120,7 @@ function PopoverContent({
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
transition={resolvedTransition}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Content>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition';
|
||||
|
||||
type SheetContextType = {
|
||||
isOpen: boolean;
|
||||
@@ -72,6 +73,7 @@ function SheetOverlay({
|
||||
transition = { duration: 0.2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: SheetOverlayProps) {
|
||||
const resolvedTransition = useReducedTransition(transition);
|
||||
return (
|
||||
<SheetPrimitive.Overlay asChild forceMount>
|
||||
<motion.div
|
||||
@@ -80,7 +82,7 @@ function SheetOverlay({
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
transition={resolvedTransition}
|
||||
{...props}
|
||||
/>
|
||||
</SheetPrimitive.Overlay>
|
||||
@@ -102,6 +104,7 @@ function SheetContent({
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
const axis = side === 'left' || side === 'right' ? 'x' : 'y';
|
||||
const resolvedTransition = useReducedTransition(transition);
|
||||
|
||||
const offscreen: Record<Side, { x?: string; y?: string; opacity: number }> = {
|
||||
right: { x: '100%', opacity: 0 },
|
||||
@@ -131,7 +134,7 @@ function SheetContent({
|
||||
...positionStyle[side],
|
||||
...style,
|
||||
}}
|
||||
transition={transition}
|
||||
transition={resolvedTransition}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useReducedMotion } from '@/hooks/use-theme';
|
||||
|
||||
const INSTANT = { duration: 0 } as const;
|
||||
|
||||
/**
|
||||
* Returns an instant (zero-duration) transition when the "Reduced motion"
|
||||
* appearance setting is on, otherwise the supplied transition. This reads our
|
||||
* app setting (not framer-motion's useReducedMotion, which only reflects the OS
|
||||
* prefers-reduced-motion). `MotionConfig`'s reducedMotion only neutralizes
|
||||
* transform/layout animations, so overlay primitives that fade or blur
|
||||
* (opacity/filter) still animate; this collapses those too.
|
||||
*/
|
||||
export function useReducedTransition<T>(transition: T): T | typeof INSTANT {
|
||||
return useReducedMotion() ? INSTANT : transition;
|
||||
}
|
||||
@@ -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 italic leading-tight tracking-[-0.01em] text-stat-value">
|
||||
<h3 className="font-serif text-2xl [font-style:var(--heading-style)] 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 italic text-base text-stat-value">{title}</span>
|
||||
<span className="font-serif [font-style:var(--heading-style)] text-base text-stat-value">{title}</span>
|
||||
<span className="text-xs text-stat-subtitle leading-relaxed">{copy}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
'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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user