mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
feat: add Reduced motion setting and polish chrome, files, and stack-detail (#1501)
A batch of UI/UX polish: - New independent "Reduced motion" appearance setting (separate from Reduced effects). Drives framer-motion via MotionConfig and clamps CSS transitions via data-motion on <html>; toasts are unaffected. Defaults off (OS preference still honored). - Stack-detail Files tab: rename "Files & Volumes" to "Files", add a persisted word-wrap toggle to the file viewer (default on), and add a fullscreen toggle that collapses the Command Center + Logs column so the editor fills the width. - Create Stack > From Git: remove the nested scroll clamp so the deploy toggle and footer are reachable. - Fleet: full-width tab band with icon-only Refresh / Export Dossier, icon-only Check-for-updates / Add-node on the Overview toolbar, theme-aware empty-state headings (calm drops the italic), and fix the Actions card body overlapping the action-row divider. - Snapshots: restyle Restore and Restore all to the ghost button design used by View / Preview / Download, and right-align the per-stack Restore. - Settings sidebar: App Store gradient active style and standard font size. - Compose Doctor: dismiss the high-risk banner (and clear the tab dot) until the findings change, via a shared fingerprint-keyed hook. - Stack-detail Storage: link the "no recent fleet snapshot" warning to the Fleet Snapshots tab (FleetView tabs are now controlled to support the deep link).
This commit is contained in:
+28
-9
@@ -1,4 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { MotionConfig } from 'motion/react';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { useReducedMotion } from './hooks/use-theme';
|
||||
import { NodeProvider } from './context/NodeContext';
|
||||
import { LicenseProvider } from './context/LicenseContext';
|
||||
import { Login } from './components/Login';
|
||||
@@ -9,6 +12,20 @@ import { DeployFeedbackProvider } from './context/DeployFeedbackContext';
|
||||
import { DeployFeedbackPortal } from './components/DeployFeedbackPortal';
|
||||
import { ToastContainer } from './components/ui/toast';
|
||||
|
||||
/** Gates framer-motion animations on the "Reduced motion" appearance setting.
|
||||
* 'always' suppresses transform/layout motion app-wide; 'user' defers to the OS
|
||||
* prefers-reduced-motion. Sonner toasts do not use framer-motion, so they are
|
||||
* unaffected. Subscribes only to the motion flag to avoid re-rendering the app
|
||||
* tree on unrelated theme changes. */
|
||||
function MotionProvider({ children }: { children: ReactNode }) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
return (
|
||||
<MotionConfig reducedMotion={reducedMotion ? 'always' : 'user'}>
|
||||
{children}
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth();
|
||||
|
||||
@@ -33,15 +50,17 @@ function AppContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeProvider>
|
||||
<LicenseProvider>
|
||||
<EditorLayout />
|
||||
{/* Portal lives inside LicenseProvider so the editor surface and its
|
||||
portalled overlays can read license state via useLicense().
|
||||
Outer DeployFeedbackProvider is still an ancestor through App. */}
|
||||
<DeployFeedbackPortal />
|
||||
</LicenseProvider>
|
||||
</NodeProvider>
|
||||
<MotionProvider>
|
||||
<NodeProvider>
|
||||
<LicenseProvider>
|
||||
<EditorLayout />
|
||||
{/* Portal lives inside LicenseProvider so the editor surface and its
|
||||
portalled overlays can read license state via useLicense().
|
||||
Outer DeployFeedbackProvider is still an ancestor through App. */}
|
||||
<DeployFeedbackPortal />
|
||||
</LicenseProvider>
|
||||
</NodeProvider>
|
||||
</MotionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ export default function EditorLayout() {
|
||||
activeView, setActiveView,
|
||||
settingsSection, setSettingsSection,
|
||||
securityTab, setSecurityTab,
|
||||
fleetTab, setFleetTab,
|
||||
filterNodeId, setFilterNodeId,
|
||||
schedulePrefill,
|
||||
mobileNavOpen, setMobileNavOpen,
|
||||
@@ -762,6 +763,8 @@ export default function EditorLayout() {
|
||||
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
|
||||
securityTab={securityTab}
|
||||
onSecurityTabChange={setSecurityTab}
|
||||
fleetTab={fleetTab}
|
||||
onFleetTabConsumed={() => setFleetTab(null)}
|
||||
renderEditor={renderEditor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -426,55 +426,53 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
||||
|
||||
{createMode === 'git' && (
|
||||
<div role="tabpanel" id={panelId('git')} aria-labelledby={tabId('git')}>
|
||||
<ScrollArea block className="max-h-[60vh]">
|
||||
<ModalBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-git-stack-name">Stack Name</Label>
|
||||
<Input
|
||||
id="create-git-stack-name"
|
||||
placeholder="Stack name (e.g., myapp)"
|
||||
value={newStackName}
|
||||
onChange={(e) => setNewStackName(e.target.value)}
|
||||
disabled={creatingFromGit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GitSourceFields
|
||||
variant="create"
|
||||
<ModalBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-git-stack-name">Stack Name</Label>
|
||||
<Input
|
||||
id="create-git-stack-name"
|
||||
placeholder="Stack name (e.g., myapp)"
|
||||
value={newStackName}
|
||||
onChange={(e) => setNewStackName(e.target.value)}
|
||||
disabled={creatingFromGit}
|
||||
repoUrl={gitRepoUrl}
|
||||
branch={gitBranch}
|
||||
composePaths={gitComposePaths}
|
||||
contextDir={gitContextDir}
|
||||
syncEnv={gitSyncEnv}
|
||||
authType={gitAuthType}
|
||||
token={gitToken}
|
||||
hasStoredToken={false}
|
||||
applyMode={gitApplyMode}
|
||||
onRepoUrlChange={setGitRepoUrl}
|
||||
onBranchChange={setGitBranch}
|
||||
onComposePathsChange={setGitComposePaths}
|
||||
onContextDirChange={setGitContextDir}
|
||||
onSyncEnvChange={setGitSyncEnv}
|
||||
onAuthTypeChange={setGitAuthType}
|
||||
onTokenChange={setGitToken}
|
||||
onApplyModeChange={setGitApplyMode}
|
||||
onBrowse={browseGitRepo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="create-git-deploy-now"
|
||||
checked={gitDeployNow}
|
||||
onCheckedChange={(c) => setGitDeployNow(c === true)}
|
||||
disabled={creatingFromGit}
|
||||
/>
|
||||
<Label htmlFor="create-git-deploy-now" className="text-xs cursor-pointer">
|
||||
Deploy after create
|
||||
</Label>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ScrollArea>
|
||||
<GitSourceFields
|
||||
variant="create"
|
||||
disabled={creatingFromGit}
|
||||
repoUrl={gitRepoUrl}
|
||||
branch={gitBranch}
|
||||
composePaths={gitComposePaths}
|
||||
contextDir={gitContextDir}
|
||||
syncEnv={gitSyncEnv}
|
||||
authType={gitAuthType}
|
||||
token={gitToken}
|
||||
hasStoredToken={false}
|
||||
applyMode={gitApplyMode}
|
||||
onRepoUrlChange={setGitRepoUrl}
|
||||
onBranchChange={setGitBranch}
|
||||
onComposePathsChange={setGitComposePaths}
|
||||
onContextDirChange={setGitContextDir}
|
||||
onSyncEnvChange={setGitSyncEnv}
|
||||
onAuthTypeChange={setGitAuthType}
|
||||
onTokenChange={setGitToken}
|
||||
onApplyModeChange={setGitApplyMode}
|
||||
onBrowse={browseGitRepo}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="create-git-deploy-now"
|
||||
checked={gitDeployNow}
|
||||
onCheckedChange={(c) => setGitDeployNow(c === true)}
|
||||
disabled={creatingFromGit}
|
||||
/>
|
||||
<Label htmlFor="create-git-deploy-now" className="text-xs cursor-pointer">
|
||||
Deploy after create
|
||||
</Label>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
hint="HTTPS REPOS ONLY"
|
||||
secondary={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useRef, useEffect } from 'react';
|
||||
import { Suspense, useRef, useEffect, useState } from 'react';
|
||||
import { Editor } from '@/lib/monacoLoader';
|
||||
import {
|
||||
Save,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
FolderOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardHeader } from '../ui/card';
|
||||
@@ -314,6 +316,14 @@ export function EditorView(props: EditorViewProps) {
|
||||
}
|
||||
}, [activeTab, canRead, setActiveTab]);
|
||||
|
||||
// Fullscreen the file browser + editor by collapsing the left column. Only
|
||||
// meaningful on the files tab; reset when leaving it or closing the editor so
|
||||
// it can never strand the compose/env panels in a single-column layout.
|
||||
const [filesFullscreen, setFilesFullscreen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!editingCompose || activeTab !== 'files') setFilesFullscreen(false);
|
||||
}, [editingCompose, activeTab]);
|
||||
|
||||
// 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.
|
||||
@@ -324,8 +334,10 @@ export function EditorView(props: EditorViewProps) {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2 min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]">
|
||||
{/* Left column: identity + health strip + logs, stacked */}
|
||||
<div className={`grid gap-6 ${filesFullscreen ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'} min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]`}>
|
||||
{/* Left column: identity + health strip + logs, stacked. Hidden in
|
||||
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) */}
|
||||
<Card className="rounded-xl border-muted bg-card shrink-0">
|
||||
@@ -396,6 +408,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{/* Logs Section (fills remaining left-column height) */}
|
||||
<StackLogsSection stackName={stackName} logsMode={logsMode} setLogsMode={setLogsMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right column: anatomy panel by default, Monaco editor when editing */}
|
||||
{editingCompose ? (
|
||||
@@ -415,7 +428,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
<TabsHighlightItem value="files">
|
||||
<TabsTrigger value="files">
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
Files & Volumes
|
||||
Files
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
@@ -485,6 +498,20 @@ export function EditorView(props: EditorViewProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'files' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8 p-0"
|
||||
onClick={() => setFilesFullscreen((v) => !v)}
|
||||
aria-label={filesFullscreen ? 'Exit full screen' : 'Full screen'}
|
||||
title={filesFullscreen ? 'Exit full screen' : 'Full screen'}
|
||||
>
|
||||
{filesFullscreen
|
||||
? <Minimize2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
: <Maximize2 className="w-4 h-4" strokeWidth={1.5} />}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -13,7 +13,7 @@ import HomeDashboard from '../HomeDashboard';
|
||||
import type { NotificationItem } from '../dashboard/types';
|
||||
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
|
||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||
// CapabilityGate wrappers render
|
||||
@@ -89,6 +89,8 @@ export interface ViewRouterProps {
|
||||
onSecurityTabChange: (tab: SecurityTab) => void;
|
||||
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
|
||||
onFleetUpdatesIntentConsumed?: () => void;
|
||||
fleetTab?: FleetTab | null;
|
||||
onFleetTabConsumed?: () => void;
|
||||
// Render slot for the inline editor view. Kept as a callback so the
|
||||
// (large) editor JSX is only allocated when activeView === 'editor',
|
||||
// not on every parent render that lands on a different view.
|
||||
@@ -116,6 +118,8 @@ export function ViewRouter({
|
||||
onSecurityTabChange,
|
||||
fleetUpdatesIntent,
|
||||
onFleetUpdatesIntentConsumed,
|
||||
fleetTab,
|
||||
onFleetTabConsumed,
|
||||
renderEditor,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
@@ -183,6 +187,8 @@ export function ViewRouter({
|
||||
onNavigateToNode={onFleetNavigateToNode}
|
||||
fleetUpdatesIntent={fleetUpdatesIntent}
|
||||
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
|
||||
fleetTab={fleetTab}
|
||||
onFleetTabConsumed={onFleetTabConsumed}
|
||||
/>
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
|
||||
@@ -155,6 +155,17 @@ describe('useViewNavigationState', () => {
|
||||
expect(result.current.filterNodeId).toBe(5);
|
||||
});
|
||||
|
||||
it('SENCHO_NAVIGATE_EVENT to fleet sets fleetTab then activeView (deep-link, no race)', () => {
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet', fleetTab: 'snapshots' } }),
|
||||
);
|
||||
});
|
||||
expect(result.current.activeView).toBe('fleet');
|
||||
expect(result.current.fleetTab).toBe('snapshots');
|
||||
});
|
||||
|
||||
it('SENCHO_NAVIGATE_EVENT with no nodeId sets filterNodeId to null', () => {
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
act(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager';
|
||||
import type { SenchoNavigateDetail } from '@/components/NodeManager';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView';
|
||||
|
||||
@@ -61,6 +61,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
const [activeView, setActiveView] = useState<ActiveView>('dashboard');
|
||||
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
|
||||
const [securityTab, setSecurityTab] = useState<SecurityTab>('overview');
|
||||
const [fleetTab, setFleetTab] = useState<FleetTab | null>(null);
|
||||
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
||||
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
@@ -96,6 +97,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
setFilterNodeId(detail.nodeId ?? null);
|
||||
return;
|
||||
}
|
||||
if (detail.view === 'fleet') {
|
||||
// Set the target sub-tab before switching so the controlled FleetView
|
||||
// lands on it (e.g. Snapshots from the stack storage warning).
|
||||
if (detail.fleetTab) setFleetTab(detail.fleetTab);
|
||||
setActiveView('fleet');
|
||||
setFilterNodeId(detail.nodeId ?? null);
|
||||
return;
|
||||
}
|
||||
setActiveView(detail.view as ActiveView);
|
||||
setFilterNodeId(detail.nodeId ?? null);
|
||||
};
|
||||
@@ -146,6 +155,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
activeView, setActiveView,
|
||||
settingsSection, setSettingsSection,
|
||||
securityTab, setSecurityTab,
|
||||
fleetTab, setFleetTab,
|
||||
filterNodeId, setFilterNodeId,
|
||||
schedulePrefill, setSchedulePrefill,
|
||||
mobileNavOpen, setMobileNavOpen,
|
||||
|
||||
@@ -651,16 +651,18 @@ export default function FleetSnapshots() {
|
||||
{/* Preserved dossier notes (read-only) */}
|
||||
{dossier && <DossierBlock dossier={dossier} />}
|
||||
|
||||
{/* Restore button (admin only) */}
|
||||
{/* Restore (admin only), right-aligned to match the file action row */}
|
||||
{isAdmin && (
|
||||
<RestoreButton
|
||||
nodeId={node.nodeId}
|
||||
nodeName={node.nodeName}
|
||||
stackName={stack.stackName}
|
||||
hasDossier={!!dossier}
|
||||
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
@@ -919,16 +921,16 @@ function RestoreButton({ nodeId, nodeName, stackName, hasDossier, restoring, onR
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs gap-1.5 ml-3 mt-1"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={restoring}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{restoring ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3 h-3" strokeWidth={1.5} />
|
||||
<RotateCcw className="w-3 h-3 mr-1" strokeWidth={1.5} />
|
||||
)}
|
||||
Restore
|
||||
</Button>
|
||||
@@ -1020,16 +1022,16 @@ function RestoreAllButton({ restoring, hasDocumentation, onRestoreAll }: {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 shrink-0"
|
||||
className="h-6 px-2 text-xs shrink-0"
|
||||
disabled={restoring}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{restoring ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
<RotateCcw className="w-3 h-3 mr-1" strokeWidth={1.5} />
|
||||
)}
|
||||
Restore all
|
||||
</Button>
|
||||
|
||||
@@ -30,14 +30,18 @@ import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
|
||||
import { SecretsTab } from './fleet/secrets/SecretsTab';
|
||||
import { DependencyMapTab } from './fleet/DependencyMapTab';
|
||||
import { useNodeActions } from './nodes/useNodeActions';
|
||||
import type { FleetTab } from '@/lib/events';
|
||||
|
||||
interface FleetViewProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
|
||||
onFleetUpdatesIntentConsumed?: () => void;
|
||||
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
|
||||
fleetTab?: FleetTab | null;
|
||||
onFleetTabConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdatesIntentConsumed }: FleetViewProps) {
|
||||
export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
@@ -55,6 +59,10 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
|
||||
|
||||
const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes');
|
||||
|
||||
// Controlled tab value so a deep-link (e.g. Snapshots from the stack storage
|
||||
// warning) can land on the right tab.
|
||||
const [activeTab, setActiveTab] = useState<FleetTab>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
if (fleetUpdatesIntent) {
|
||||
setInitialUpdatesTab(fleetUpdatesIntent.tab);
|
||||
@@ -64,6 +72,13 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
|
||||
}
|
||||
}, [fleetUpdatesIntent, updateStatus, onFleetUpdatesIntentConsumed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fleetTab) {
|
||||
setActiveTab(fleetTab);
|
||||
onFleetTabConsumed?.();
|
||||
}
|
||||
}, [fleetTab, onFleetTabConsumed]);
|
||||
|
||||
const { mastheadStats, lastSyncAt, loading, refreshing } = overview;
|
||||
|
||||
const { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({
|
||||
@@ -86,8 +101,8 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<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]">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="overview">
|
||||
@@ -146,16 +161,17 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
|
||||
)}
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2 max-md:w-full max-md:flex-wrap">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => overview.fetchOverview(true)}
|
||||
disabled={refreshing}
|
||||
className="gap-2"
|
||||
className="h-9 w-9 p-0"
|
||||
title="Refresh"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
@@ -163,10 +179,11 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
|
||||
size="sm"
|
||||
onClick={() => { void exportDossier(); }}
|
||||
disabled={exporting}
|
||||
className="gap-2"
|
||||
className="h-9 w-9 p-0"
|
||||
title="Export Dossier"
|
||||
aria-label="Export Dossier"
|
||||
>
|
||||
<FileDown className={`w-4 h-4 ${exporting ? 'animate-pulse' : ''}`} />
|
||||
{exporting ? 'Exporting…' : 'Export Dossier'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -259,19 +259,25 @@ export function OverviewToolbar({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 shrink-0 h-9"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={onCheckUpdates}
|
||||
disabled={checkingUpdates}
|
||||
title="Check for updates"
|
||||
aria-label="Check for updates"
|
||||
>
|
||||
<RefreshCcwDot className={`w-4 h-4 ${checkingUpdates ? 'animate-spin' : ''}`} />
|
||||
Check Updates
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onAddNode && (
|
||||
<Button size="sm" className="gap-1.5 shrink-0 h-9" onClick={onAddNode}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 shrink-0"
|
||||
onClick={onAddNode}
|
||||
title="Add node"
|
||||
aria-label="Add node"
|
||||
>
|
||||
<Plus className="w-4 h-4" strokeWidth={1.5} />
|
||||
Add node
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -97,17 +97,17 @@ describe('OverviewToolbar', () => {
|
||||
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 Updates/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Check for 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 Updates/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Check for 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 Updates/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Check for updates/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodeActions, type NodeTestInfo } from './nodes/useNodeActions';
|
||||
import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus';
|
||||
import { resetFleetSyncAnchor, STICKY_CONTROL_IDENTITY_MISMATCH } from '@/lib/fleetSyncApi';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
|
||||
interface NodeSchedulingSummary {
|
||||
active_tasks: number;
|
||||
@@ -30,10 +30,12 @@ interface NodeSchedulingSummary {
|
||||
|
||||
export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate';
|
||||
export interface SenchoNavigateDetail {
|
||||
view: 'scheduled-ops' | 'auto-updates' | 'security';
|
||||
view: 'scheduled-ops' | 'auto-updates' | 'security' | 'fleet';
|
||||
nodeId?: number;
|
||||
/** Target tab when navigating to the Security view. */
|
||||
tab?: SecurityTab;
|
||||
/** Target tab when navigating to the Fleet view (e.g. 'snapshots'). */
|
||||
fleetTab?: FleetTab;
|
||||
}
|
||||
|
||||
export function NodeManager() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ScrollableTabRow } from './ui/ScrollableTabRow';
|
||||
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 { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy';
|
||||
import { buildServiceUrl } from '@/lib/serviceUrl';
|
||||
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
||||
@@ -108,6 +109,12 @@ export default function StackAnatomyPanel({
|
||||
// active tab content lazily, so the badge cannot come from PreflightPanel; the
|
||||
// parent reads the stored run once per stack/node change.
|
||||
const [preflightSeverity, setPreflightSeverity] = useState<string | null>(null);
|
||||
// Findings power the dismiss fingerprint so the dot clears in lockstep with the
|
||||
// banner and re-appears when the findings change.
|
||||
const [preflightFindings, setPreflightFindings] = useState<Array<{ ruleId: string; severity: string; service?: string }> | undefined>(undefined);
|
||||
// The Doctor tab dot clears when the high-risk banner is dismissed, and returns
|
||||
// when the findings change (shared fingerprint with PreflightPanel).
|
||||
const { dismissed: doctorDismissed } = usePreflightDismiss(stackName, activeNode?.id, preflightFindings);
|
||||
const [scanStatus, setScanStatus] = useState<{
|
||||
status: 'ok' | 'partial' | 'failed' | 'skipped' | null;
|
||||
attemptedAt?: number;
|
||||
@@ -126,9 +133,12 @@ export default function StackAnatomyPanel({
|
||||
const res = await apiFetch(`/stacks/${stackName}/preflight`);
|
||||
if (cancelled || !res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!cancelled) setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
|
||||
if (!cancelled) {
|
||||
setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
|
||||
setPreflightFindings(Array.isArray(data?.findings) ? data.findings : undefined);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setPreflightSeverity(null);
|
||||
if (!cancelled) { setPreflightSeverity(null); setPreflightFindings(undefined); }
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
@@ -363,7 +373,7 @@ export default function StackAnatomyPanel({
|
||||
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Doctor
|
||||
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && (
|
||||
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && !doctorDismissed && (
|
||||
<span
|
||||
data-testid="doctor-tab-dot"
|
||||
className={cn('h-1.5 w-1.5 rounded-full', preflightSeverity === 'blocker' ? 'bg-destructive' : 'bg-warning')}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, useEffect, useMemo, useRef, Suspense } from 'react';
|
||||
import { Editor } from '@/lib/monacoLoader';
|
||||
import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react';
|
||||
import { AlertCircle, FileIcon, Download, Loader2, Save, WrapText } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { readStackFile, writeStackFile, downloadStackFile, FileConflictError } from '@/lib/stackFilesApi';
|
||||
import { extensionToLanguage } from '@/lib/monacoLanguages';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { cn, formatBytes } from '@/lib/utils';
|
||||
|
||||
const WORD_WRAP_KEY = 'sencho.fileViewer.wordWrap';
|
||||
|
||||
interface FileViewerProps {
|
||||
stackName: string;
|
||||
@@ -131,6 +133,15 @@ export function FileViewer({
|
||||
const readOnly = !canEdit;
|
||||
const hasChanges = content !== originalContent;
|
||||
|
||||
// Word wrap, persisted across files and sessions. Defaults on so wide files
|
||||
// do not require horizontal scrolling; only an explicit 'false' disables it.
|
||||
const [wordWrap, setWordWrap] = useState(() => {
|
||||
try { return localStorage.getItem(WORD_WRAP_KEY) !== 'false'; } catch { return true; }
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(WORD_WRAP_KEY, String(wordWrap)); } catch { /* ignore */ }
|
||||
}, [wordWrap]);
|
||||
|
||||
// Stash the latest callback in a ref so the unmount-cleanup effect can be
|
||||
// truly unmount-scoped without re-running every time a parent passes a fresh
|
||||
// function identity.
|
||||
@@ -164,8 +175,9 @@ export function FileViewer({
|
||||
fontSize: 13,
|
||||
padding: { top: 8 },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: wordWrap ? ('on' as const) : ('off' as const),
|
||||
}),
|
||||
[readOnly],
|
||||
[readOnly, wordWrap],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -350,6 +362,17 @@ export function FileViewer({
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-glass-border shrink-0">
|
||||
<span className="font-mono text-xs text-stat-subtitle truncate">{filename}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn('h-7 w-7 p-0', wordWrap ? 'text-brand' : 'text-stat-subtitle')}
|
||||
onClick={() => setWordWrap((v) => !v)}
|
||||
aria-pressed={wordWrap}
|
||||
title={wordWrap ? 'Word wrap on' : 'Word wrap off'}
|
||||
aria-label={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
>
|
||||
<WrapText className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
{readOnly && (
|
||||
<span className="text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle border border-border rounded px-1.5 py-0.5">
|
||||
Read-only
|
||||
|
||||
@@ -8,15 +8,16 @@ interface FleetTabHeadingProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized Fleet tab header: italic-serif title and muted subtitle on the
|
||||
* Standardized Fleet tab header: heading-styled title and muted subtitle on the
|
||||
* left, an optional primary action on the right. Rendered in both empty and
|
||||
* populated states so the tab chrome stays consistent.
|
||||
* populated states so the tab chrome stays consistent. The title follows the
|
||||
* theme heading style (signature italic or calm) via .font-heading.
|
||||
*/
|
||||
export function FleetTabHeading({ title, subtitle, action }: FleetTabHeadingProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display italic text-[1.5rem] leading-tight text-stat-value">{title}</h2>
|
||||
<h2 className="font-heading text-[1.5rem] leading-tight text-stat-value">{title}</h2>
|
||||
<p className="text-sm text-stat-subtitle">{subtitle}</p>
|
||||
</div>
|
||||
{action}
|
||||
@@ -44,15 +45,15 @@ interface FleetEmptyCardProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal empty-state card: centered icon, italic headline, muted one-line
|
||||
* description, optional CTA.
|
||||
* Minimal empty-state card: centered icon, heading-styled headline, muted
|
||||
* one-line description, optional CTA.
|
||||
*/
|
||||
export function FleetEmptyCard({ icon: Icon, title, description, action }: FleetEmptyCardProps) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-xl border border-card-border/60 bg-popover/30 p-8 text-center space-y-4">
|
||||
<Icon className="mx-auto w-8 h-8 text-stat-subtitle" />
|
||||
<div>
|
||||
<h3 className="font-display italic text-[1.25rem] text-stat-value">{title}</h3>
|
||||
<h3 className="font-heading text-[1.25rem] text-stat-value">{title}</h3>
|
||||
<p className="text-sm text-stat-subtitle leading-relaxed mt-1">{description}</p>
|
||||
</div>
|
||||
{action}
|
||||
|
||||
@@ -140,9 +140,9 @@ export function AppearanceSection() {
|
||||
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
|
||||
const {
|
||||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||||
headingStyle, chartStyle, reducedEffects, readability,
|
||||
headingStyle, chartStyle, reducedEffects, reducedMotion, readability,
|
||||
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
|
||||
setVisualStyle, setHeadingStyle, setChartStyle, setReducedEffects, setReadability,
|
||||
setVisualStyle, setHeadingStyle, setChartStyle, setReducedEffects, setReducedMotion, setReadability,
|
||||
} = useTheme();
|
||||
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
|
||||
// Readability is a sticky master: it forces the calm resolution at apply time
|
||||
@@ -252,6 +252,17 @@ export function AppearanceSection() {
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Reduced motion"
|
||||
helper="Minimizes interface animations and transitions (dialogs, menus, expand and collapse). Toasts are unaffected."
|
||||
>
|
||||
<TogglePill
|
||||
checked={reducedMotion}
|
||||
onChange={setReducedMotion}
|
||||
aria-label="Reduced motion"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Ambient glow"
|
||||
helper="Intensity of the accent-tinted glow behind the page."
|
||||
|
||||
@@ -81,16 +81,16 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors',
|
||||
'relative flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
isActive
|
||||
? 'text-stat-value'
|
||||
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
|
||||
? 'text-brand bg-gradient-to-r from-brand/[0.12] to-transparent'
|
||||
: 'text-foreground/80 hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1 left-0 w-[3px] rounded-full bg-brand shadow-[0_0_8px_color-mix(in_oklch,var(--brand)_30%,transparent)]"
|
||||
className="absolute inset-y-0 left-0 w-[2px] rounded-sm bg-brand"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
|
||||
@@ -13,6 +13,7 @@ function resetTheme() {
|
||||
result.current.setVisualStyle('signature');
|
||||
result.current.setContrast(0);
|
||||
result.current.setGlow(0.16);
|
||||
result.current.setReducedMotion(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +51,17 @@ describe('AppearanceSection', () => {
|
||||
expect(container.querySelectorAll('[data-disabled]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reduced motion is independent of readability and toggles data-motion on <html>', () => {
|
||||
render(<AppearanceSection />);
|
||||
const motion = () => screen.getByRole('switch', { name: 'Reduced motion' }) as HTMLButtonElement;
|
||||
expect(document.documentElement.dataset.motion).toBeUndefined();
|
||||
// Readability flattens effects but must not disable the motion toggle.
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
expect(motion().disabled).toBe(false);
|
||||
fireEvent.click(motion());
|
||||
expect(document.documentElement.dataset.motion).toBe('reduced');
|
||||
});
|
||||
|
||||
it('readability also locks the Visual style cards and the Border brightness slider', () => {
|
||||
const { container } = render(<AppearanceSection />);
|
||||
const calmCard = () => screen.getByRole('button', { name: /readable default/i }) as HTMLButtonElement;
|
||||
|
||||
@@ -42,7 +42,7 @@ function jsonRes(body: unknown, ok = true) {
|
||||
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); });
|
||||
|
||||
describe('PreflightPanel', () => {
|
||||
it('shows the never-run empty state', async () => {
|
||||
@@ -75,6 +75,19 @@ describe('PreflightPanel', () => {
|
||||
expect(screen.getByText('Image uses a moving tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses only the result banner, keeping the finding rows', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'high', highestSeverity: 'high',
|
||||
findings: [{ ruleId: 'privileged', severity: 'high', title: 'Privileged container', message: 'runs privileged', service: 'web' }],
|
||||
})));
|
||||
render(<PreflightPanel stackName="web" />);
|
||||
expect(await screen.findByTestId('preflight-status')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('preflight-dismiss-btn'));
|
||||
expect(screen.queryByTestId('preflight-status')).not.toBeInTheDocument();
|
||||
// Only the summary banner is dismissed; the finding row remains.
|
||||
expect(screen.getByText('Privileged container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the unrenderable state with the render error', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
renderable: false, status: 'unrenderable', highestSeverity: 'blocker',
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, type LucideIcon,
|
||||
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, X, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
|
||||
|
||||
// Mirrors the backend payload shape (the frontend never imports backend).
|
||||
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
|
||||
@@ -152,6 +153,10 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
const SummaryIcon = summary?.icon;
|
||||
const busy = loading || running;
|
||||
|
||||
// Dismiss the result banner (and the Doctor tab dot) until the findings change.
|
||||
const { dismissed, dismiss } = usePreflightDismiss(stackName, nodeId, report?.findings);
|
||||
const hasFindings = (report?.findings.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div data-testid="preflight-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -192,9 +197,21 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{summary && SummaryIcon && (
|
||||
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{summary && SummaryIcon && !dismissed && (
|
||||
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone, 'relative')}>
|
||||
{hasFindings && (
|
||||
<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>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pr-6">
|
||||
<SummaryIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide">{summary.label}</span>
|
||||
{report.ranAt && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager';
|
||||
|
||||
// Mirrors the backend /storage payload (the frontend never imports backend).
|
||||
type PortabilityStatus = 'portable' | 'partially-portable' | 'node-bound' | 'unknown';
|
||||
@@ -221,6 +222,17 @@ export default function StoragePanel({ stackName }: { stackName: string }) {
|
||||
This stack has persistent storage but no fleet snapshot in the last 7 days.
|
||||
</span>
|
||||
</div>
|
||||
{activeNode?.type !== 'remote' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
|
||||
detail: { view: 'fleet', fleetTab: 'snapshots' },
|
||||
}))}
|
||||
className="mt-1.5 text-[12px] font-medium text-brand hover:underline"
|
||||
>
|
||||
Take a fleet snapshot →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && inventory.stateful && snapshot.recent && snapshot.at && (
|
||||
|
||||
@@ -174,7 +174,9 @@ export function FleetActionCard(props: FleetActionCardProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6 flex-1">
|
||||
{/* pt-4 so the first SheetSection (which zeroes its own top padding) clears
|
||||
the action row's bottom border instead of overlapping it. */}
|
||||
<div className="px-6 pt-4 flex-1">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { usePreflightDismiss } from '../usePreflightDismiss';
|
||||
|
||||
const findings = (sev: string) => [
|
||||
{ ruleId: 'DS001', severity: sev, service: 'web' },
|
||||
{ ruleId: 'DS002', severity: 'warning' },
|
||||
];
|
||||
|
||||
describe('usePreflightDismiss', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('is not dismissed until dismiss() is called', () => {
|
||||
const { result } = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.dismissed).toBe(true);
|
||||
});
|
||||
|
||||
it('stays dismissed for an identical finding set (order-independent)', () => {
|
||||
const first = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
|
||||
act(() => first.result.current.dismiss());
|
||||
// A fresh consumer with the same findings in a different order reads dismissed.
|
||||
const reordered = [...findings('high')].reverse();
|
||||
const second = renderHook(() => usePreflightDismiss('app', 1, reordered));
|
||||
expect(second.result.current.dismissed).toBe(true);
|
||||
});
|
||||
|
||||
it('re-surfaces when the findings change', () => {
|
||||
const first = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
|
||||
act(() => first.result.current.dismiss());
|
||||
// Severity changed -> different fingerprint -> not dismissed.
|
||||
const changed = renderHook(() => usePreflightDismiss('app', 1, findings('blocker')));
|
||||
expect(changed.result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('keys per stack and node', () => {
|
||||
const a = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
|
||||
act(() => a.result.current.dismiss());
|
||||
expect(renderHook(() => usePreflightDismiss('other', 1, findings('high'))).result.current.dismissed).toBe(false);
|
||||
expect(renderHook(() => usePreflightDismiss('app', 2, findings('high'))).result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty finding set as never dismissed', () => {
|
||||
const { result } = renderHook(() => usePreflightDismiss('app', 1, []));
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,9 @@ export interface ThemeState {
|
||||
headingStyle: HeadingStyle;
|
||||
chartStyle: ChartStyle;
|
||||
reducedEffects: boolean;
|
||||
/** Independent of reducedEffects (surface flattening): minimizes UI motion
|
||||
* (dialogs, menus, overlays, transitions). Not part of a visual-style preset. */
|
||||
reducedMotion: boolean;
|
||||
readability: boolean;
|
||||
}
|
||||
|
||||
@@ -136,6 +139,9 @@ const DEFAULT_STATE: ThemeState = {
|
||||
theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0,
|
||||
uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1,
|
||||
...CALM_PRESET,
|
||||
// Independent of the visual-style presets; defaults off so the OS
|
||||
// prefers-reduced-motion still governs via MotionConfig's 'user' mode.
|
||||
reducedMotion: false,
|
||||
};
|
||||
|
||||
const MODE_IDS = new Set<string>(THEME_MODES.map((m) => m.id));
|
||||
@@ -200,6 +206,7 @@ function readStored(): ThemeState {
|
||||
headingStyle: isHeadingStyle(p.headingStyle) ? p.headingStyle : SIGNATURE_PRESET.headingStyle,
|
||||
chartStyle: isChartStyle(p.chartStyle) ? p.chartStyle : SIGNATURE_PRESET.chartStyle,
|
||||
reducedEffects: isBool(p.reducedEffects) ? p.reducedEffects : SIGNATURE_PRESET.reducedEffects,
|
||||
reducedMotion: isBool(p.reducedMotion) ? p.reducedMotion : false,
|
||||
readability: isBool(p.readability) ? p.readability : SIGNATURE_PRESET.readability,
|
||||
};
|
||||
}
|
||||
@@ -252,6 +259,9 @@ function applyToDom(s: ThemeState, systemDark: boolean) {
|
||||
root.dataset.chartStyle = chart;
|
||||
if (reduced) root.dataset.effects = 'reduced';
|
||||
else delete root.dataset.effects;
|
||||
// Motion is independent of effects/readability: only the explicit toggle.
|
||||
if (s.reducedMotion) root.dataset.motion = 'reduced';
|
||||
else delete root.dataset.motion;
|
||||
root.style.setProperty('--border-boost', String(rd ? 0.03 : s.borderBoost));
|
||||
root.style.setProperty('--glow', String(reduced ? s.glow * 0.4 : s.glow));
|
||||
root.style.setProperty('--contrast', String(s.contrast + (rd ? 0.18 : 0)));
|
||||
@@ -284,6 +294,7 @@ function sameState(a: ThemeState, b: ThemeState): boolean {
|
||||
&& a.uiFont === b.uiFont && a.monoFont === b.monoFont && a.typeScale === b.typeScale
|
||||
&& a.visualStyle === b.visualStyle && a.headingStyle === b.headingStyle
|
||||
&& a.chartStyle === b.chartStyle && a.reducedEffects === b.reducedEffects
|
||||
&& a.reducedMotion === b.reducedMotion
|
||||
&& a.readability === b.readability;
|
||||
}
|
||||
|
||||
@@ -312,6 +323,16 @@ function getSnapshot(): ThemeSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Lean selector for just the reduced-motion flag, so a wrapper like MotionConfig
|
||||
* re-renders only when motion changes, not on every theme tweak. */
|
||||
export function useReducedMotion(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => persisted.reducedMotion,
|
||||
() => DEFAULT_STATE.reducedMotion,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// Cross-tab sync: another tab wrote a new look.
|
||||
window.addEventListener('storage', (e) => {
|
||||
@@ -366,6 +387,7 @@ export function useTheme() {
|
||||
const setHeadingStyle = useCallback((headingStyle: HeadingStyle) => setState({ headingStyle }), []);
|
||||
const setChartStyle = useCallback((chartStyle: ChartStyle) => setState({ chartStyle }), []);
|
||||
const setReducedEffects = useCallback((reducedEffects: boolean) => setState({ reducedEffects }), []);
|
||||
const setReducedMotion = useCallback((reducedMotion: boolean) => setState({ reducedMotion }), []);
|
||||
const setReadability = useCallback((readability: boolean) => setState({ readability }), []);
|
||||
const resolvedTheme = resolveWith(s.theme, s.systemDark);
|
||||
return {
|
||||
@@ -381,6 +403,7 @@ export function useTheme() {
|
||||
headingStyle: s.headingStyle,
|
||||
chartStyle: s.chartStyle,
|
||||
reducedEffects: s.reducedEffects,
|
||||
reducedMotion: s.reducedMotion,
|
||||
readability: s.readability,
|
||||
resolvedTheme,
|
||||
isDarkMode: resolvedTheme !== 'light',
|
||||
@@ -396,6 +419,7 @@ export function useTheme() {
|
||||
setHeadingStyle,
|
||||
setChartStyle,
|
||||
setReducedEffects,
|
||||
setReducedMotion,
|
||||
setReadability,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
/** Minimal shape of a preflight finding needed to fingerprint a result set. */
|
||||
interface FindingLike {
|
||||
ruleId: string;
|
||||
severity: string;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
// Bumped when a dismiss is written so sibling consumers (the Doctor tab dot and
|
||||
// the banner) re-read localStorage and agree, without a full page reload.
|
||||
const DISMISS_EVENT = 'sencho:preflight-dismiss-changed';
|
||||
|
||||
const keyFor = (stackName: string, nodeId: number | undefined) =>
|
||||
`sencho.doctorDismissed.${stackName}.${nodeId ?? 'local'}`;
|
||||
|
||||
/** Stable, content-based fingerprint of the findings. Order-independent so a
|
||||
* reordered-but-identical result still counts as dismissed; any added, removed,
|
||||
* or re-severitied finding changes it, which re-surfaces the banner. */
|
||||
function fingerprint(findings: FindingLike[] | undefined): string {
|
||||
if (!findings || findings.length === 0) return '';
|
||||
return findings
|
||||
.map((f) => `${f.ruleId}:${f.severity}:${f.service ?? ''}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-stack dismiss for the Compose Doctor high-risk banner, persisted in
|
||||
* localStorage and keyed to a fingerprint of the findings: the dismissal sticks
|
||||
* across reloads and re-runs that produce identical findings, and clears
|
||||
* automatically once the findings change. Used by both the banner (to hide
|
||||
* itself) and the Doctor tab dot (to clear), kept in sync via a window event.
|
||||
*/
|
||||
export function usePreflightDismiss(
|
||||
stackName: string,
|
||||
nodeId: number | undefined,
|
||||
findings: FindingLike[] | undefined,
|
||||
) {
|
||||
const fp = useMemo(() => fingerprint(findings), [findings]);
|
||||
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 };
|
||||
}
|
||||
@@ -759,6 +759,18 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* The "Reduced motion" appearance setting (data-motion="reduced" on <html>)
|
||||
clamps CSS animations/transitions, mirroring the OS preference above. Sonner
|
||||
toasts animate via inline transform/opacity (React state), not CSS, so they
|
||||
are unaffected. framer-motion is handled separately by MotionConfig. */
|
||||
[data-motion="reduced"] *,
|
||||
[data-motion="reduced"] *::before,
|
||||
[data-motion="reduced"] *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
XTERM.JS
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -34,3 +34,16 @@ export type SecurityTab =
|
||||
| 'suppressions'
|
||||
| 'history'
|
||||
| 'scanner';
|
||||
|
||||
/** Fleet view sub-tabs, used for deep-link navigation (e.g. the stack storage
|
||||
* warning linking to Snapshots). Mirrors the TabsTrigger values in FleetView. */
|
||||
export type FleetTab =
|
||||
| 'overview'
|
||||
| 'snapshots'
|
||||
| 'configuration'
|
||||
| 'dependencies'
|
||||
| 'deployments'
|
||||
| 'routing'
|
||||
| 'federation'
|
||||
| 'actions'
|
||||
| 'secrets';
|
||||
|
||||
Reference in New Issue
Block a user