mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
chore: merge main into fix/ui-polish, resolve StackRow conflict
This commit is contained in:
@@ -128,6 +128,8 @@ export default function EditorLayout() {
|
||||
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
||||
stackUpdates,
|
||||
fetchImageUpdates,
|
||||
sidebarIndicators,
|
||||
sidebarStackUpdates,
|
||||
pinned,
|
||||
isCollapsed, toggleCollapse,
|
||||
remoteSearchLoading,
|
||||
@@ -669,7 +671,7 @@ export default function EditorLayout() {
|
||||
stackLabelMap,
|
||||
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
|
||||
stackCounts,
|
||||
stackUpdates,
|
||||
stackUpdates: sidebarStackUpdates,
|
||||
gitSourcePendingMap,
|
||||
pinnedFiles: pinned,
|
||||
isCollapsed,
|
||||
@@ -697,6 +699,7 @@ export default function EditorLayout() {
|
||||
onToggleSelect={toggleSelect}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkAction={handleBulkAction}
|
||||
showUpdatesChip={sidebarIndicators}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -777,6 +780,7 @@ export default function EditorLayout() {
|
||||
fleetTab={fleetTab}
|
||||
onFleetTabConsumed={() => setFleetTab(null)}
|
||||
renderEditor={renderEditor}
|
||||
stackUpdates={stackUpdates}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ import ErrorBoundary from '../ErrorBoundary';
|
||||
import StackAnatomyPanel from '../StackAnatomyPanel';
|
||||
import { StackFileExplorer } from '@/components/files/StackFileExplorer';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
|
||||
import { MobileStackDetail } from './MobileStackDetail';
|
||||
import { RecoveryChip } from './RecoveryChip';
|
||||
@@ -355,7 +356,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{/* Command Center Card (identity + health strip). Hidden when
|
||||
the logs are expanded so the logs pane fills the column. */}
|
||||
{!logsExpanded && (
|
||||
<Card className="rounded-xl border-muted bg-card shrink-0">
|
||||
<Card className={`rounded-xl border-muted bg-card ${safeContainers.length > 1 ? 'flex flex-col min-h-0 max-h-[42%]' : 'shrink-0'}`}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -407,6 +408,23 @@ export function EditorView(props: EditorViewProps) {
|
||||
panelStartedAt={panelStartedAt}
|
||||
variant="band"
|
||||
/>
|
||||
{safeContainers.length > 1 ? (
|
||||
<CardContent className="p-4 pt-2 flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<ContainersHealth
|
||||
safeContainers={safeContainers}
|
||||
containerStats={containerStats}
|
||||
containerStatsError={containerStatsError}
|
||||
isAdmin={isAdmin}
|
||||
activeNode={activeNode}
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="p-4 pt-2">
|
||||
<ContainersHealth
|
||||
safeContainers={safeContainers}
|
||||
@@ -417,12 +435,17 @@ export function EditorView(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Logs Section (fills remaining left-column height) */}
|
||||
{/* Logs Section (fills remaining left-column height). On multi-
|
||||
container stacks a min-h guarantees logs are never hidden. */}
|
||||
{safeContainers.length > 1 ? (
|
||||
<div className="flex-1 min-h-[180px] flex flex-col">
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
@@ -430,6 +453,16 @@ export function EditorView(props: EditorViewProps) {
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
setLogsMode={setLogsMode}
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { NotificationItem } from '../dashboard/types';
|
||||
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
|
||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||
@@ -99,6 +100,7 @@ export interface ViewRouterProps {
|
||||
// (large) editor JSX is only allocated when activeView === 'editor',
|
||||
// not on every parent render that lands on a different view.
|
||||
renderEditor: () => ReactNode;
|
||||
stackUpdates: Record<string, StackUpdateInfo>;
|
||||
}
|
||||
|
||||
export function ViewRouter({
|
||||
@@ -128,6 +130,7 @@ export function ViewRouter({
|
||||
fleetTab,
|
||||
onFleetTabConsumed,
|
||||
renderEditor,
|
||||
stackUpdates,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
if (activeView === 'settings') {
|
||||
@@ -251,6 +254,7 @@ export function ViewRouter({
|
||||
onOpenSettingsSection={onOpenSettingsSection}
|
||||
notifications={notifications}
|
||||
onClearNotifications={onClearNotifications}
|
||||
stackUpdates={stackUpdates}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,3 +80,140 @@ describe('ContainersHealth published port link', () => {
|
||||
expect(screen.getByText(/8080 → 80\/tcp/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('density toggle and summary strip', () => {
|
||||
function makeContainer(overrides: Partial<ContainerInfo> = {}): ContainerInfo {
|
||||
return {
|
||||
Id: overrides.Id || 'abc',
|
||||
Names: overrides.Names || ['/app'],
|
||||
State: overrides.State || 'running',
|
||||
Status: overrides.Status || 'Up 1 hour',
|
||||
Image: overrides.Image || 'nginx',
|
||||
...overrides,
|
||||
} as unknown as ContainerInfo;
|
||||
}
|
||||
|
||||
function renderMany(containers: ContainerInfo[]) {
|
||||
return render(
|
||||
<ContainersHealth
|
||||
safeContainers={containers}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('does not render summary strip or density toggle for a single container', () => {
|
||||
renderMany([makeContainer()]);
|
||||
expect(screen.queryByText(/container/)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Detailed view' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders summary counts for multiple containers', () => {
|
||||
renderMany([
|
||||
makeContainer({ Id: 'a', State: 'running' }),
|
||||
makeContainer({ Id: 'b', State: 'running' }),
|
||||
makeContainer({ Id: 'c', State: 'paused' }),
|
||||
]);
|
||||
expect(screen.getByText(/3 containers/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 up/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 paused/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows unhealthy count in summary', () => {
|
||||
renderMany([
|
||||
makeContainer({ Id: 'a', State: 'running', healthStatus: 'healthy' }),
|
||||
makeContainer({ Id: 'b', State: 'running', healthStatus: 'unhealthy' }),
|
||||
]);
|
||||
expect(screen.getByText(/1 unhealthy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders density toggle buttons for multiple containers', () => {
|
||||
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
|
||||
expect(screen.getByRole('button', { name: 'Compact view' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Detailed view' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('detailed mode is the default', () => {
|
||||
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
|
||||
const detailed = screen.getByRole('button', { name: 'Detailed view' });
|
||||
expect(detailed).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('hides sparkline grids in compact mode', () => {
|
||||
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
|
||||
// Sparklines visible by default in detailed mode (two containers, two cpu labels)
|
||||
expect(screen.getAllByText('cpu')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
|
||||
// Sparkline labels hidden in compact mode
|
||||
expect(screen.queryByText('cpu')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows sparkline grids again when switching back to detailed', () => {
|
||||
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
|
||||
expect(screen.queryByText('cpu')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Detailed view' }));
|
||||
expect(screen.getAllByText('cpu')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps header row actions visible in compact mode', () => {
|
||||
renderMany([
|
||||
makeContainer({ Id: 'a', State: 'running', Service: 'web' }),
|
||||
makeContainer({ Id: 'b', State: 'running' }),
|
||||
]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
|
||||
// View logs button still present
|
||||
expect(screen.getAllByRole('button', { name: 'View logs' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders empty state for zero containers without summary strip', () => {
|
||||
renderMany([]);
|
||||
expect(screen.getByText(/no containers running/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Detailed view' })).toBeNull();
|
||||
});
|
||||
|
||||
it('resets density to detailed on remount (key change)', () => {
|
||||
const { unmount } = render(
|
||||
<ContainersHealth
|
||||
safeContainers={[makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Switch to compact
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
|
||||
expect(screen.queryByText('cpu')).toBeNull();
|
||||
|
||||
// Simulate navigating to a single-container stack (new key)
|
||||
unmount();
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[makeContainer({ Id: 'x' })]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Density reset; single container shows sparklines
|
||||
expect(screen.getByText('cpu')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
ArrowUpRight,
|
||||
Copy,
|
||||
CloudDownload,
|
||||
Layers,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -339,6 +341,9 @@ export function ContainersHealth({
|
||||
}: ContainersHealthProps) {
|
||||
const [copiedUrlId, setCopiedUrlId] = useState<string | null>(null);
|
||||
const copiedUrlTimerRef = useRef<number | null>(null);
|
||||
// Compact mode hides sparkline grids across all containers for a denser
|
||||
// list. Detailed mode (default) shows CPU / Mem / Net per container.
|
||||
const [density, setDensity] = useState<'compact' | 'detailed'>('detailed');
|
||||
useEffect(() => () => {
|
||||
if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current);
|
||||
}, []);
|
||||
@@ -368,7 +373,46 @@ export function ContainersHealth({
|
||||
{safeContainers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<>
|
||||
{/* Summary strip + density toggle appear only for multi-container
|
||||
stacks; single-container stacks keep the original layout. */}
|
||||
{safeContainers.length > 1 && (() => {
|
||||
const total = safeContainers.length;
|
||||
const running = safeContainers.filter(c => c.State === 'running').length;
|
||||
const unhealthy = safeContainers.filter(c => c.healthStatus === 'unhealthy').length;
|
||||
const paused = safeContainers.filter(c => c.State === 'paused').length;
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-1 px-1">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
<span>{total} container{total !== 1 ? 's' : ''}</span>
|
||||
<span className="text-success/80">{running} up</span>
|
||||
{paused > 0 && <span className="text-warning/80">{paused} paused</span>}
|
||||
{unhealthy > 0 && <span className="text-destructive/80">{unhealthy} unhealthy</span>}
|
||||
</div>
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('compact')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'compact'}
|
||||
aria-label="Compact view"
|
||||
>
|
||||
<List className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('detailed')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'detailed'}
|
||||
aria-label="Detailed view"
|
||||
>
|
||||
<Layers className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="flex flex-col gap-2">
|
||||
{safeContainers.map(container => {
|
||||
let mainPort: number | undefined;
|
||||
let mainPortPrivate: number | undefined;
|
||||
@@ -517,7 +561,7 @@ export function ContainersHealth({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive ? (
|
||||
{isActive && density === 'detailed' ? (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
@@ -552,6 +596,7 @@ export function ContainersHealth({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse';
|
||||
import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions';
|
||||
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
|
||||
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
||||
import type { StackAction, StackActionResult } from '../EditorView';
|
||||
import type { Label as StackLabel } from '../../label-types';
|
||||
@@ -60,6 +61,8 @@ export interface RemoteResult {
|
||||
files: Array<{ file: string; status: StackRowStatus }>;
|
||||
}
|
||||
|
||||
const EMPTY_UPDATES: Record<string, StackUpdateInfo> = {};
|
||||
|
||||
export function useStackListState() {
|
||||
const { nodes, activeNode } = useNodes();
|
||||
|
||||
@@ -96,7 +99,8 @@ export function useStackListState() {
|
||||
const [bulkMode, setBulkMode] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const { stackUpdates, refresh: fetchImageUpdates } = useImageUpdates(activeNode?.id);
|
||||
const { stackUpdates, refresh: fetchImageUpdates, sidebarIndicators } = useImageUpdates(activeNode?.id);
|
||||
const sidebarStackUpdates = sidebarIndicators ? stackUpdates : EMPTY_UPDATES;
|
||||
const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id);
|
||||
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
|
||||
const { runBulk } = useBulkStackActions();
|
||||
@@ -295,16 +299,16 @@ export function useStackListState() {
|
||||
all: filteredFiles.length,
|
||||
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
|
||||
down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length,
|
||||
updates: filteredFiles.filter(f => stackUpdates[f]?.hasUpdate).length,
|
||||
}), [filteredFiles, stackStatuses, stackUpdates]);
|
||||
updates: filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate).length,
|
||||
}), [filteredFiles, stackStatuses, sidebarStackUpdates]);
|
||||
|
||||
const chipFilteredFiles = useMemo(() => {
|
||||
if (filterChip === 'all') return filteredFiles;
|
||||
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
|
||||
if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f]));
|
||||
if (filterChip === 'updates') return filteredFiles.filter(f => stackUpdates[f]?.hasUpdate);
|
||||
if (filterChip === 'updates') return filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate);
|
||||
return filteredFiles;
|
||||
}, [filteredFiles, filterChip, stackStatuses, stackUpdates]);
|
||||
}, [filteredFiles, filterChip, stackStatuses, sidebarStackUpdates]);
|
||||
|
||||
const toggleBulkMode = useCallback(() => {
|
||||
setBulkMode(prev => {
|
||||
@@ -381,6 +385,15 @@ export function useStackListState() {
|
||||
});
|
||||
}, [remoteStackResults, nodes]);
|
||||
|
||||
// When the sidebar indicator toggle is turned off, reset an active Updates
|
||||
// filter to 'all' so the user is not stuck in a filter that shows nothing.
|
||||
useEffect(() => {
|
||||
if (!sidebarIndicators && filterChip === 'updates') {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFilterChip('all');
|
||||
}
|
||||
}, [sidebarIndicators, filterChip]);
|
||||
|
||||
return {
|
||||
files, setFiles, filesNodeId,
|
||||
selectedFile, setSelectedFile,
|
||||
@@ -410,6 +423,7 @@ export function useStackListState() {
|
||||
scheduleStateInvalidateRefresh,
|
||||
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
||||
stackUpdates, fetchImageUpdates,
|
||||
sidebarIndicators, sidebarStackUpdates,
|
||||
pinned, pin, unpin, isPinned,
|
||||
isCollapsed, toggleCollapse,
|
||||
remoteSearchLoading,
|
||||
|
||||
@@ -145,13 +145,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="deployments">
|
||||
<TabsHighlightItem value="deployments">
|
||||
<TabsTrigger value="deployments">
|
||||
<Send className="w-4 h-4 mr-1.5" />Deployments
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="routing">
|
||||
<TabsTrigger value="routing">
|
||||
@@ -159,13 +157,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="federation">
|
||||
<TabsHighlightItem value="federation">
|
||||
<TabsTrigger value="federation">
|
||||
<Network className="w-4 h-4 mr-1.5" />Federation
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<TabsHighlightItem value="actions">
|
||||
<TabsTrigger value="actions">
|
||||
<Wrench className="w-4 h-4 mr-1.5" />Actions
|
||||
@@ -274,11 +270,9 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
<ContainerLabelsTab onNavigateToNode={onNavigateToNode} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsContent value="deployments">
|
||||
<TabsContent value="deployments">
|
||||
<DeploymentsTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsContent value="routing">
|
||||
<PaidGate>
|
||||
@@ -286,13 +280,9 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
</PaidGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsContent value="federation">
|
||||
<PaidGate>
|
||||
<FederationTab canManage={isAdmin} />
|
||||
</PaidGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="federation">
|
||||
<FederationTab canManage={isAdmin} />
|
||||
</TabsContent>
|
||||
<TabsContent value="actions">
|
||||
{/* Fleet Actions runs against the whole fleet, so it takes the
|
||||
unfiltered node list rather than the overview-filtered view. */}
|
||||
|
||||
@@ -21,8 +21,8 @@ import { formatBytes } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes, type Node } from '@/context/NodeContext';
|
||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
@@ -71,15 +71,15 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
const [cordonReason, setCordonReason] = useState('');
|
||||
const [cordonSubmitting, setCordonSubmitting] = useState(false);
|
||||
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { nodes: registryNodes } = useNodes();
|
||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||
const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1;
|
||||
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default);
|
||||
// Cordon is a paid feature AND requires node:manage, matching the backend guard
|
||||
// (requirePermission('node:manage','node',id) + requirePaid). Gating on tier
|
||||
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
||||
// Cordon requires the paid tier AND node:manage, matching the backend guard
|
||||
// (requirePermission('node:manage','node',id) + requirePaid).
|
||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
const nodeMuteActions = useNodeMuteActions(
|
||||
node.id,
|
||||
|
||||
@@ -61,6 +61,13 @@ describe('NodeCard', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the actions menu for a Community admin with node:manage when cordon requires Admiral', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
// Cordon is Admiral-only; without edit/delete props, no menu items are available to Community.
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the actions menu (cordon entry point) for a paid admin', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
@@ -69,7 +76,7 @@ describe('NodeCard', () => {
|
||||
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the cordon control for a node-admin via the node:manage permission', async () => {
|
||||
it('exposes the cordon control for an Admiral node-admin via the node:manage permission', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
import type { SectionId } from './settings/types';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import {
|
||||
HealthStatusBar,
|
||||
ResourceGauges,
|
||||
@@ -16,11 +17,12 @@ interface HomeDashboardProps {
|
||||
onOpenSettingsSection?: (section: SectionId) => void;
|
||||
notifications: NotificationItem[];
|
||||
onClearNotifications: () => void | Promise<void>;
|
||||
stackUpdates?: Record<string, StackUpdateInfo>;
|
||||
}
|
||||
|
||||
const NOOP = () => {};
|
||||
|
||||
export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications }: HomeDashboardProps) {
|
||||
export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications, stackUpdates = {} }: HomeDashboardProps) {
|
||||
const { activeNode, nodes } = useNodes();
|
||||
const data = useDashboardData();
|
||||
const activeNodeName = activeNode?.name || 'Local';
|
||||
@@ -49,6 +51,7 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection
|
||||
metrics={data.metrics}
|
||||
stackCpuSeries={data.stackCpuSeries}
|
||||
onNavigateToStack={onNavigateToStack ?? NOOP}
|
||||
stackUpdates={stackUpdates}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
@@ -288,7 +288,7 @@ export function NodeManager() {
|
||||
{resettingAnchor === nodeId ? 'Resetting...' : 'Reset anchor on peer'}
|
||||
</Button>
|
||||
)}
|
||||
{node && !node.is_default && (isAdmin || can('node:manage', 'node', String(nodeId))) && (
|
||||
{node && !node.is_default && nodes.filter(n => n.type === 'local').length > 1 && (isAdmin || can('node:manage', 'node', String(nodeId))) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -324,6 +324,7 @@ export function NodeManager() {
|
||||
<TableBody>
|
||||
{nodes.map((node) => {
|
||||
const canManageThis = isAdmin || can('node:manage', 'node', String(node.id));
|
||||
const isLastLocal = node.type === 'local' && nodes.filter(n => n.type === 'local').length <= 1;
|
||||
return (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell>
|
||||
@@ -493,7 +494,7 @@ export function NodeManager() {
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{!node.is_default && canManageThis && (
|
||||
{!node.is_default && !isLastLocal && canManageThis && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -25,7 +25,7 @@ beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/preflight')) {
|
||||
return jsonRes({ stack: 'web', ranAt: 1, ranBy: 'x', renderable: true, renderError: null, status: 'high', highestSeverity: badgeSeverity, findings: [] });
|
||||
return jsonRes({ stack: 'web', ranAt: 1, ranBy: 'x', renderable: true, renderError: null, status: 'high', highestSeverity: badgeSeverity, activeStatus: badgeSeverity === 'warning' ? 'warning' : 'high', activeHighestSeverity: badgeSeverity, activeCount: 0, acknowledgedCount: 0, findings: [] });
|
||||
}
|
||||
return jsonRes(null, false); // git-source, update-preview, scan-status
|
||||
});
|
||||
|
||||
@@ -23,16 +23,21 @@ import StackAnatomyPanel from './StackAnatomyPanel';
|
||||
|
||||
const COMPOSE = 'services:\n web:\n image: nginx:1.25\n';
|
||||
|
||||
function previewBody(hasUpdate: boolean) {
|
||||
function previewBody(hasUpdate: boolean, buildServices: string[] = []) {
|
||||
const hasBuild = buildServices.length > 0;
|
||||
return {
|
||||
build_services: buildServices,
|
||||
summary: {
|
||||
has_update: hasUpdate,
|
||||
primary_image: 'nginx',
|
||||
current_tag: '1.25',
|
||||
next_tag: '1.26',
|
||||
semver_bump: 'minor',
|
||||
update_kind: hasUpdate ? 'tag' : 'none',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: hasBuild,
|
||||
rebuild_available: hasBuild,
|
||||
},
|
||||
changelog: null,
|
||||
};
|
||||
@@ -86,6 +91,21 @@ describe('StackAnatomyPanel update banner', () => {
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows Rebuild & Update for build-only stacks', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/update-preview')) return jsonRes(previewBody(false, ['app']));
|
||||
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
|
||||
return jsonRes(null, false);
|
||||
});
|
||||
|
||||
render(panel(false));
|
||||
|
||||
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Rebuild available/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Rebuild & Update' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the apply button and shows progress while applying', async () => {
|
||||
const onApply = vi.fn();
|
||||
const { rerender } = render(panel(false, onApply));
|
||||
|
||||
@@ -40,6 +40,7 @@ interface StackAnatomyPanelProps {
|
||||
}
|
||||
|
||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
type UpdateKind = 'tag' | 'digest' | 'none';
|
||||
|
||||
interface UpdatePreviewSummary {
|
||||
has_update: boolean;
|
||||
@@ -47,12 +48,16 @@ interface UpdatePreviewSummary {
|
||||
current_tag: string | null;
|
||||
next_tag: string | null;
|
||||
semver_bump: SemverBump;
|
||||
update_kind?: UpdateKind;
|
||||
blocked: boolean;
|
||||
blocked_reason: string | null;
|
||||
has_build_services: boolean;
|
||||
rebuild_available: boolean;
|
||||
}
|
||||
|
||||
interface UpdatePreview {
|
||||
summary: UpdatePreviewSummary;
|
||||
build_services?: string[];
|
||||
changelog: string | null;
|
||||
}
|
||||
|
||||
@@ -143,8 +148,9 @@ export default function StackAnatomyPanel({
|
||||
if (cancelled || !res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
|
||||
setPreflightFindings(Array.isArray(data?.findings) ? data.findings : undefined);
|
||||
setPreflightSeverity(typeof data?.activeHighestSeverity === 'string' ? data.activeHighestSeverity : null);
|
||||
const findings = Array.isArray(data?.findings) ? data.findings : undefined;
|
||||
setPreflightFindings(findings?.filter((f: { acknowledged?: boolean }) => !f.acknowledged));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) { setPreflightSeverity(null); setPreflightFindings(undefined); }
|
||||
@@ -337,6 +343,10 @@ export default function StackAnatomyPanel({
|
||||
|
||||
const bump = updatePreview?.summary.semver_bump ?? 'none';
|
||||
const hasUpdate = Boolean(updatePreview?.summary.has_update);
|
||||
const hasBuildServices = Boolean(updatePreview?.summary.has_build_services);
|
||||
const rebuildAvailable = Boolean(updatePreview?.summary.rebuild_available);
|
||||
const showUpdateBanner = hasUpdate || rebuildAvailable;
|
||||
const updateKind = updatePreview?.summary.update_kind ?? 'none';
|
||||
const blocked = Boolean(updatePreview?.summary.blocked);
|
||||
const bannerSeverity: 'danger' | 'warn' | 'ok' = bump === 'major' || blocked
|
||||
? 'danger'
|
||||
@@ -354,13 +364,29 @@ export default function StackAnatomyPanel({
|
||||
const bumpLabel = bump === 'none' || bump === 'unknown' ? '' : `${bump}`;
|
||||
const bannerLeadIn = blocked
|
||||
? 'review required'
|
||||
: bump === 'patch'
|
||||
? 'safe to apply'
|
||||
: bump === 'minor'
|
||||
? 'review recommended'
|
||||
: bump === 'major'
|
||||
? 'breaking changes possible'
|
||||
: '';
|
||||
: hasUpdate && updateKind === 'digest'
|
||||
? 'same-tag digest rebuild'
|
||||
: hasUpdate && hasBuildServices
|
||||
? 'registry update + local rebuild'
|
||||
: rebuildAvailable && !hasUpdate
|
||||
? 'local build / rebuild required'
|
||||
: bump === 'patch'
|
||||
? 'safe to apply'
|
||||
: bump === 'minor'
|
||||
? 'review recommended'
|
||||
: bump === 'major'
|
||||
? 'breaking changes possible'
|
||||
: '';
|
||||
const buildServiceNames = updatePreview?.build_services ?? [];
|
||||
const buildHint = hasBuildServices
|
||||
? `Rebuilds ${buildServiceNames.length} local build service${buildServiceNames.length === 1 ? '' : 's'} from Dockerfile context; may take longer and needs network access for base images.`
|
||||
: '';
|
||||
const gitRebuildHint = hasBuildServices && activeGitSource
|
||||
? 'After applying Git source changes, use Rebuild & Update to deploy the updated source.'
|
||||
: '';
|
||||
const applyLabel = hasBuildServices
|
||||
? (applying ? 'rebuilding...' : 'Rebuild & Update')
|
||||
: (applying ? 'applying...' : 'apply');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
|
||||
@@ -531,13 +557,13 @@ export default function StackAnatomyPanel({
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
{hasUpdate && updatePreview && (
|
||||
{showUpdateBanner && updatePreview && (
|
||||
<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-xs uppercase tracking-wide">
|
||||
Update available
|
||||
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
|
||||
{hasBuildServices && !hasUpdate ? 'Rebuild available' : 'Update available'}
|
||||
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && hasUpdate && (
|
||||
<span className="text-foreground">
|
||||
{' · '}
|
||||
<span className="text-stat-subtitle">{updatePreview.summary.current_tag}</span>
|
||||
@@ -550,6 +576,8 @@ export default function StackAnatomyPanel({
|
||||
{[
|
||||
bumpLabel,
|
||||
bannerLeadIn,
|
||||
buildHint,
|
||||
gitRebuildHint,
|
||||
updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '',
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
@@ -567,7 +595,7 @@ export default function StackAnatomyPanel({
|
||||
onClick={onApplyUpdate}
|
||||
>
|
||||
<Rocket className={cn('h-3 w-3', applying && 'animate-pulse')} strokeWidth={1.5} />
|
||||
{applying ? 'applying...' : 'apply'}
|
||||
{applyLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -646,7 +674,7 @@ export default function StackAnatomyPanel({
|
||||
)}
|
||||
{doctorEnabled && (
|
||||
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<PreflightPanel stackName={stackName} />
|
||||
<PreflightPanel stackName={stackName} canEdit={canEdit} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{storageEnabled && (
|
||||
|
||||
@@ -16,13 +16,11 @@ import { BlueprintEmptyState } from './BlueprintEmptyState';
|
||||
import { FleetTabHeading, FleetEmptyState } from '../fleet/FleetEmptyState';
|
||||
import { BlueprintDetail } from './BlueprintDetail';
|
||||
import { BlueprintEditor } from './BlueprintEditor';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export function DeploymentsTab() {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const canEdit = isPaid && isAdmin;
|
||||
const canEdit = isAdmin;
|
||||
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
|
||||
const [distinctLabels, setDistinctLabels] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Sparkline } from '@/components/ui/sparkline';
|
||||
import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
||||
import { classifyRow, type RowState } from './classifyRow';
|
||||
|
||||
@@ -12,6 +13,7 @@ interface StackHealthTableProps {
|
||||
metrics: MetricPoint[];
|
||||
stackCpuSeries: Record<string, StackCpuSeries>;
|
||||
onNavigateToStack: (stackFile: string) => void;
|
||||
stackUpdates?: Record<string, StackUpdateInfo>;
|
||||
}
|
||||
|
||||
type SortKey = 'stack' | 'up' | 'cpu' | 'mem';
|
||||
@@ -90,6 +92,7 @@ export function StackHealthTable({
|
||||
metrics,
|
||||
stackCpuSeries,
|
||||
onNavigateToStack,
|
||||
stackUpdates = {},
|
||||
}: StackHealthTableProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
// null = the default health-state ordering (worst first); a SortKey switches
|
||||
@@ -127,9 +130,10 @@ export function StackHealthTable({
|
||||
runningSince: entry.runningSince ?? null,
|
||||
source: entry.source ?? 'local',
|
||||
mainPort: entry.mainPort ?? null,
|
||||
hasUpdate: stackUpdates[file]?.hasUpdate ?? false,
|
||||
};
|
||||
});
|
||||
}, [stackStatuses, stackAggregates, stackCpuSeries]);
|
||||
}, [stackStatuses, stackAggregates, stackCpuSeries, stackUpdates]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const list = [...baseRows];
|
||||
@@ -246,7 +250,14 @@ export function StackHealthTable({
|
||||
className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-[var(--density-row-x)] py-[var(--density-row-y)] transition-colors hover:bg-accent/5 ${rowTint[row.state]}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full justify-self-center ${stateDot[row.state]}`} aria-hidden="true" />
|
||||
<span className="truncate font-mono text-sm text-stat-value">{row.name}</span>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-sm text-stat-value">{row.name}</span>
|
||||
{row.hasUpdate && (
|
||||
<span className="shrink-0 rounded-full bg-update/15 px-2 py-0.5 font-mono text-[10px] leading-none text-update tracking-wide">
|
||||
Update available
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[11px] uppercase tracking-wide text-stat-subtitle">
|
||||
{row.source === 'git' ? 'Git' : 'Local'}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||
@@ -159,9 +158,8 @@ function NodeDetail({
|
||||
onInspectStack: (nodeId: number, stackName: string) => void;
|
||||
onCordonChange: () => void;
|
||||
}) {
|
||||
const { isPaid } = useLicense();
|
||||
const { can } = useAuth();
|
||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
const canCordon = can('node:manage', 'node', String(node.id));
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
|
||||
@@ -275,40 +275,55 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-type">Type</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(val) => {
|
||||
const type = val as NodeFormData['type'];
|
||||
const currentDefault = defaultComposeDir(formData.type, formData.mode);
|
||||
setFormData({
|
||||
...formData,
|
||||
type,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
compose_dir: formData.compose_dir === currentDefault
|
||||
? defaultComposeDir(type, formData.mode)
|
||||
: formData.compose_dir,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="node-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Local - Docker socket on this machine
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="remote">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Remote - another Sencho instance
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isEdit ? (
|
||||
<div className="flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-muted/50">
|
||||
{formData.type === 'local' ? (
|
||||
<><Monitor className="w-4 h-4 text-muted-foreground" /><span className="text-sm text-muted-foreground">Local</span></>
|
||||
) : (
|
||||
<><Globe className="w-4 h-4 text-muted-foreground" /><span className="text-sm text-muted-foreground">Remote</span></>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(val) => {
|
||||
const type = val as NodeFormData['type'];
|
||||
const currentDefault = defaultComposeDir(formData.type, formData.mode);
|
||||
setFormData({
|
||||
...formData,
|
||||
type,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
compose_dir: formData.compose_dir === currentDefault
|
||||
? defaultComposeDir(type, formData.mode)
|
||||
: formData.compose_dir,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="node-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!nodes.some(n => n.type === 'local') && (
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Local - Docker socket on this machine
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="remote">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Remote - another Sencho instance
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground">Node type cannot be changed after creation.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.type === 'remote' && (
|
||||
@@ -581,9 +596,15 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes <span className="font-medium text-stat-value">{deletingNode?.name}</span> from this console. The remote instance and its containers are not affected.
|
||||
</p>
|
||||
{deletingNode?.type === 'local' ? (
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Deleting local node <span className="font-medium text-stat-value">{deletingNode?.name}</span> removes its schedules, labels, dossiers, findings, and other node-scoped data. Containers and compose files on the host are <strong>not</strong> affected. This action cannot be undone.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes <span className="font-medium text-stat-value">{deletingNode?.name}</span> from this console. The remote instance and its containers are not affected.
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -12,6 +13,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { formatTimeAgo, formatTimeUntil } from '@/lib/relativeTime';
|
||||
@@ -61,6 +63,49 @@ export function UpdatesSection() {
|
||||
|
||||
const intervalMinutes = status?.intervalMinutes ?? null;
|
||||
|
||||
// Mirror activeNode.id in a ref so the PATCH handler can detect a node
|
||||
// switch mid-flight and discard a stale write.
|
||||
const activeNodeIdRef = useRef(activeNode?.id ?? null);
|
||||
activeNodeIdRef.current = activeNode?.id ?? null;
|
||||
|
||||
// Derive toggle state from the current status. When the field is missing
|
||||
// (older remote node) the toggle is disabled with a helpful message.
|
||||
const sidebarIndicators = status?.sidebarIndicators ?? false;
|
||||
const nodeSupportsSidebarSetting = status !== null && status.sidebarIndicators !== undefined;
|
||||
|
||||
const handleSidebarIndicatorsChange = useCallback(async (next: boolean) => {
|
||||
const targetNodeId = activeNodeIdRef.current;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
nodeId: targetNodeId ?? null,
|
||||
body: JSON.stringify({ image_update_sidebar_indicators: next ? '1' : '0' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to update setting');
|
||||
}
|
||||
// Guard: if the active node changed while the PATCH was in flight,
|
||||
// discard the response — it belongs to a different node.
|
||||
if (activeNodeIdRef.current === targetNodeId) {
|
||||
setStatus(prev => prev ? { ...prev, sidebarIndicators: next } : prev);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
|
||||
detail: { changedKeys: ['image_update_sidebar_indicators'] },
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Only surface the error if the active node hasn't changed. A
|
||||
// stale failure from node A must not toast while the user views
|
||||
// node B.
|
||||
if (activeNodeIdRef.current === targetNodeId) {
|
||||
toast.error((e as Error)?.message || 'Failed to update sidebar indicator setting.');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useMastheadStats(
|
||||
isLoading || intervalMinutes == null
|
||||
? null
|
||||
@@ -70,6 +115,7 @@ export function UpdatesSection() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchStatus = async () => {
|
||||
setStatus(null);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/status');
|
||||
@@ -280,6 +326,25 @@ export function UpdatesSection() {
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Sidebar" kicker="node-scoped">
|
||||
<SettingsField
|
||||
label="Show update status in sidebar"
|
||||
helper={
|
||||
status !== null && status.sidebarIndicators === undefined
|
||||
? "This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it."
|
||||
: "Show a pulsing dot when a stack has an available update and a warning icon when the check fails. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected."
|
||||
}
|
||||
htmlFor="sidebar-indicators-toggle"
|
||||
>
|
||||
<TogglePill
|
||||
id="sidebar-indicators-toggle"
|
||||
checked={sidebarIndicators}
|
||||
onChange={handleSidebarIndicatorsChange}
|
||||
disabled={status === null || !nodeSupportsSidebarSetting || readOnly || isSaving}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
group: 'automation',
|
||||
label: 'Image update checks',
|
||||
description: 'How often this node polls registries to detect available image updates and raise notifications.',
|
||||
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck'],
|
||||
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck', 'sidebar', 'badge', 'dot', 'indicator', 'status'],
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface PatchableSettings {
|
||||
health_gate_enabled?: '0' | '1';
|
||||
health_gate_window_seconds?: string;
|
||||
env_block_deploy_on_missing_required?: '0' | '1';
|
||||
image_update_sidebar_indicators?: '0' | '1';
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
@@ -44,6 +45,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
health_gate_enabled: '1',
|
||||
health_gate_window_seconds: '90',
|
||||
env_block_deploy_on_missing_required: '0',
|
||||
image_update_sidebar_indicators: '1',
|
||||
};
|
||||
|
||||
export type SectionId =
|
||||
|
||||
@@ -15,6 +15,7 @@ interface SidebarFilterChipsProps {
|
||||
onChange: (chip: FilterChip) => void;
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
showUpdatesChip?: boolean;
|
||||
}
|
||||
|
||||
const chips: { id: FilterChip; label: string }[] = [
|
||||
@@ -24,12 +25,13 @@ const chips: { id: FilterChip; label: string }[] = [
|
||||
{ id: 'updates', label: 'Updates' },
|
||||
];
|
||||
|
||||
export function SidebarFilterChips({ active, counts, onChange, visible, onToggle }: SidebarFilterChipsProps) {
|
||||
export function SidebarFilterChips({ active, counts, onChange, visible, onToggle, showUpdatesChip = true }: SidebarFilterChipsProps) {
|
||||
const visibleChips = showUpdatesChip ? chips : chips.filter(c => c.id !== 'updates');
|
||||
return (
|
||||
<div className="flex items-center pb-1.5 pt-0.5 pl-2">
|
||||
{visible ? (
|
||||
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
|
||||
{chips.map(({ id, label }) => {
|
||||
{visibleChips.map(({ id, label }) => {
|
||||
const count = counts[id];
|
||||
const displayCount = count > 99 ? '99+' : count;
|
||||
const isActive = active === id;
|
||||
|
||||
@@ -205,7 +205,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
<CommandItem
|
||||
value={file}
|
||||
onSelect={() => onSelectFile(file)}
|
||||
className="p-0 data-[selected=true]:bg-transparent"
|
||||
className="min-w-0 w-full p-0 data-[selected=true]:bg-transparent"
|
||||
>
|
||||
<StackRow
|
||||
file={file}
|
||||
|
||||
@@ -104,11 +104,11 @@ export function StackRow(props: StackRowProps) {
|
||||
<span className="flex-1 truncate font-mono text-sm min-w-0">{displayName}</span>
|
||||
|
||||
{/* Fixed trailing icon slot: update dot > check-failed > git pending */}
|
||||
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0">
|
||||
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0" data-testid="stack-row-trailing">
|
||||
{hasUpdate ? (
|
||||
<RowTooltip
|
||||
trigger={(
|
||||
<span className="relative inline-flex w-2 h-2">
|
||||
<span className="relative inline-flex w-2 h-2" data-testid="stack-trailing-update">
|
||||
<span className="absolute inset-0 rounded-full bg-update opacity-75 animate-ping" />
|
||||
<span className="relative w-2 h-2 rounded-full bg-update" />
|
||||
</span>
|
||||
@@ -117,12 +117,12 @@ export function StackRow(props: StackRowProps) {
|
||||
/>
|
||||
) : checkStatus === 'failed' ? (
|
||||
<RowTooltip
|
||||
trigger={<span><AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} /></span>}
|
||||
trigger={<span data-testid="stack-trailing-check-failed"><AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} /></span>}
|
||||
label={lastError ? `Update check failed: ${lastError}` : 'Update check failed'}
|
||||
/>
|
||||
) : hasGitPending ? (
|
||||
<RowTooltip
|
||||
trigger={<span><GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} /></span>}
|
||||
trigger={<span data-testid="stack-trailing-git-pending"><GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} /></span>}
|
||||
label="Git source update pending"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface StackSidebarProps {
|
||||
onToggleSelect: (file: string) => void;
|
||||
onClearSelection: () => void;
|
||||
onBulkAction: (action: BulkAction) => void;
|
||||
showUpdatesChip?: boolean;
|
||||
}
|
||||
|
||||
export function StackSidebar(props: StackSidebarProps) {
|
||||
@@ -41,6 +42,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
|
||||
list, activitySummary, onActivityAction,
|
||||
bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
|
||||
showUpdatesChip = true,
|
||||
} = props;
|
||||
|
||||
const [filtersVisible, setFiltersVisible] = useState(() => {
|
||||
@@ -84,6 +86,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
onChange={onFilterChipChange}
|
||||
visible={filtersVisible}
|
||||
onToggle={handleToggleFilters}
|
||||
showUpdatesChip={showUpdatesChip}
|
||||
/>
|
||||
{selectedFiles.size > 0 && (
|
||||
<SidebarBulkBar
|
||||
@@ -92,7 +95,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
onClear={onClearSelection}
|
||||
/>
|
||||
)}
|
||||
<ScrollArea className="flex-1 px-2 pb-2">
|
||||
<ScrollArea block className="flex-1 px-2 pb-2">
|
||||
<div data-stacks-loaded={list.isLoading ? 'false' : 'true'}>
|
||||
<StackList {...list} bulkMode={bulkMode} selectedFiles={selectedFiles} onToggleSelect={onToggleSelect} />
|
||||
</div>
|
||||
|
||||
@@ -121,4 +121,11 @@ describe('StackRow', () => {
|
||||
expect(container.querySelector('.lucide-alert-circle')).toBeNull();
|
||||
expect(container.querySelector('.bg-update')).toBeNull();
|
||||
});
|
||||
|
||||
it('constrains long stack names so trailing indicators stay in the row', () => {
|
||||
const longName = 'tick-grafana-docker-observability-stack';
|
||||
render(<StackRow {...base({ displayName: longName })} />);
|
||||
expect(screen.getByTestId('stack-row')).toHaveClass('min-w-0');
|
||||
expect(screen.getByText(longName)).toHaveClass('truncate');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const sidebarRowBase = cn(
|
||||
'relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md mb-0.5',
|
||||
'relative flex items-center gap-2 w-full min-w-0 px-2 py-1.5 rounded-md mb-0.5',
|
||||
// 44px tap target on touch viewports without changing desktop density.
|
||||
'max-md:min-h-11 max-md:py-2.5',
|
||||
'font-mono text-[13px] text-muted-foreground',
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Finding {
|
||||
sourcePath?: string;
|
||||
remediation?: string;
|
||||
service?: string;
|
||||
acknowledged?: boolean;
|
||||
}
|
||||
interface Report {
|
||||
stack: string;
|
||||
@@ -31,11 +32,30 @@ interface Report {
|
||||
renderError: string | null;
|
||||
status: string;
|
||||
highestSeverity: string | null;
|
||||
activeStatus: string;
|
||||
activeHighestSeverity: string | null;
|
||||
activeCount: number;
|
||||
acknowledgedCount: number;
|
||||
findings: Finding[];
|
||||
}
|
||||
|
||||
function report(partial: Partial<Report>): Report {
|
||||
return { stack: 'web', ranAt: 1000, ranBy: 'admin', renderable: true, renderError: null, status: 'pass', highestSeverity: null, findings: [], ...partial };
|
||||
const base: Report = {
|
||||
stack: 'web', ranAt: 1000, ranBy: 'admin',
|
||||
renderable: true, renderError: null,
|
||||
status: 'pass', highestSeverity: null,
|
||||
activeStatus: 'pass', activeHighestSeverity: null,
|
||||
activeCount: 0, acknowledgedCount: 0,
|
||||
findings: [],
|
||||
};
|
||||
const merged = { ...base, ...partial };
|
||||
// Derive activeStatus/activeHighestSeverity/activeCount from the old
|
||||
// fields when the caller only set those (so existing tests work without
|
||||
// every call site listing the new field names).
|
||||
if (partial.status !== undefined && partial.activeStatus === undefined) merged.activeStatus = merged.status;
|
||||
if (partial.highestSeverity !== undefined && partial.activeHighestSeverity === undefined) merged.activeHighestSeverity = merged.highestSeverity;
|
||||
if (partial.findings !== undefined && partial.activeCount === undefined) merged.activeCount = merged.findings.filter(f => !f.acknowledged).length;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function jsonRes(body: unknown, ok = true) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, X, type LucideIcon,
|
||||
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, X,
|
||||
ChevronDown, ChevronRight, ShieldCheck, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -8,10 +9,14 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
|
||||
// Mirrors the backend payload shape (the frontend never imports backend).
|
||||
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
|
||||
type PreflightStatus = 'never-run' | 'pass' | 'unrenderable' | PreflightSeverity;
|
||||
type PreflightAckExpiryMode = 'forever' | 'until_compose_change' | 'days' | 'until_image_change';
|
||||
|
||||
interface PreflightFinding {
|
||||
ruleId: string;
|
||||
@@ -21,6 +26,10 @@ interface PreflightFinding {
|
||||
sourcePath?: string;
|
||||
remediation?: string;
|
||||
service?: string;
|
||||
acknowledged?: boolean;
|
||||
acknowledgementId?: number;
|
||||
acknowledgementReason?: string;
|
||||
acknowledgementExpiry?: PreflightAckExpiryMode;
|
||||
}
|
||||
|
||||
interface PreflightReport {
|
||||
@@ -31,10 +40,15 @@ interface PreflightReport {
|
||||
renderError: string | null;
|
||||
status: PreflightStatus;
|
||||
highestSeverity: PreflightSeverity | null;
|
||||
activeStatus: PreflightStatus;
|
||||
activeHighestSeverity: PreflightSeverity | null;
|
||||
activeCount: number;
|
||||
acknowledgedCount: number;
|
||||
findings: PreflightFinding[];
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
const MODAL_FIELD_LABEL = LABEL_CLASS;
|
||||
const ACTION_CLASS =
|
||||
'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40';
|
||||
const CARD_CLASS = 'rounded-lg border px-3 py-2.5';
|
||||
@@ -48,7 +62,13 @@ const SEVERITY_META: Record<PreflightSeverity, { label: string; icon: LucideIcon
|
||||
|
||||
const GROUP_ORDER: PreflightSeverity[] = ['blocker', 'high', 'warning', 'info'];
|
||||
|
||||
/** The header summary card: a single read on the overall result. */
|
||||
const EXPIRY_LABELS: Record<PreflightAckExpiryMode, string> = {
|
||||
forever: 'Forever',
|
||||
until_compose_change: 'Until Compose changes',
|
||||
days: '30 days',
|
||||
until_image_change: 'Until image changes',
|
||||
};
|
||||
|
||||
function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon; tone: string; line: string } {
|
||||
if (!report.renderable) {
|
||||
return {
|
||||
@@ -58,26 +78,61 @@ function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon
|
||||
line: report.renderError ?? 'Sencho could not render the effective Compose model.',
|
||||
};
|
||||
}
|
||||
if (report.findings.length === 0) {
|
||||
if (report.activeCount === 0 && report.acknowledgedCount === 0) {
|
||||
return { label: 'all clear', icon: Check, tone: 'border-success/40 bg-success/[0.06] text-success', line: 'No issues found in the effective model.' };
|
||||
}
|
||||
const meta = SEVERITY_META[report.highestSeverity ?? 'info'];
|
||||
const counts = GROUP_ORDER
|
||||
.map(sev => ({ sev, n: report.findings.filter(f => f.severity === sev).length }))
|
||||
const meta = SEVERITY_META[report.activeHighestSeverity ?? 'info'];
|
||||
const activeParts = GROUP_ORDER
|
||||
.map(sev => ({ sev, n: report.findings.filter(f => !f.acknowledged && f.severity === sev).length }))
|
||||
.filter(c => c.n > 0)
|
||||
.map(c => `${c.n} ${SEVERITY_META[c.sev].label}`)
|
||||
.join(' · ');
|
||||
return { label: meta.label, icon: meta.icon, tone: meta.tone, line: counts };
|
||||
const line = report.acknowledgedCount > 0
|
||||
? `${report.activeCount} active${activeParts ? ` (${activeParts})` : ''} · ${report.acknowledgedCount} acknowledged`
|
||||
: (activeParts || `${report.activeCount} active`);
|
||||
return { label: report.activeCount === 0 ? 'acknowledged' : meta.label, icon: report.activeCount === 0 ? ShieldCheck : meta.icon, tone: report.activeCount === 0 ? 'border-muted bg-card/40 text-stat-subtitle' : meta.tone, line };
|
||||
}
|
||||
|
||||
function FindingRow({ finding }: { finding: PreflightFinding }) {
|
||||
function expiryComboboxOptions(finding: PreflightFinding): ComboboxOption[] {
|
||||
const options: ComboboxOption[] = [
|
||||
{ value: 'forever', label: EXPIRY_LABELS.forever },
|
||||
{ value: 'until_compose_change', label: EXPIRY_LABELS.until_compose_change },
|
||||
{ value: 'days', label: EXPIRY_LABELS.days },
|
||||
];
|
||||
if (finding.service) {
|
||||
options.push({ value: 'until_image_change', label: EXPIRY_LABELS.until_image_change });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function FindingRow({
|
||||
finding,
|
||||
canEdit,
|
||||
onAcknowledge,
|
||||
}: {
|
||||
finding: PreflightFinding;
|
||||
canEdit: boolean;
|
||||
onAcknowledge?: (finding: PreflightFinding) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-muted py-2 first:border-t-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{finding.service && (
|
||||
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{finding.service && (
|
||||
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-foreground/90">{finding.title}</span>
|
||||
</div>
|
||||
{canEdit && onAcknowledge && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`preflight-ack-btn-${finding.ruleId}-${finding.service ?? 'stack'}`}
|
||||
onClick={() => onAcknowledge(finding)}
|
||||
className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand"
|
||||
>
|
||||
acknowledge
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-foreground/90">{finding.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-relaxed text-foreground/80">{finding.message}</div>
|
||||
{finding.remediation && (
|
||||
@@ -92,7 +147,50 @@ function FindingRow({ finding }: { finding: PreflightFinding }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
function AcknowledgedRow({
|
||||
finding,
|
||||
canEdit,
|
||||
onClear,
|
||||
}: {
|
||||
finding: PreflightFinding;
|
||||
canEdit: boolean;
|
||||
onClear: (finding: PreflightFinding) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-muted py-2 first:border-t-0 opacity-80">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{finding.service && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-stat-subtitle">{finding.service}</span>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-foreground/80">{finding.title}</span>
|
||||
</div>
|
||||
{finding.acknowledgementReason && (
|
||||
<div className="mt-1 text-[11px] text-stat-subtitle">{finding.acknowledgementReason}</div>
|
||||
)}
|
||||
{finding.acknowledgementExpiry && (
|
||||
<div className="mt-0.5 font-mono text-[10px] text-stat-subtitle">
|
||||
expires: {EXPIRY_LABELS[finding.acknowledgementExpiry]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && finding.acknowledgementId != null && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`preflight-clear-ack-${finding.acknowledgementId}`}
|
||||
onClick={() => onClear(finding)}
|
||||
className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PreflightPanel({ stackName, canEdit = false }: { stackName: string; canEdit?: boolean }) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const [report, setReport] = useState<PreflightReport | null>(null);
|
||||
@@ -100,9 +198,29 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [ackOpen, setAckOpen] = useState(false);
|
||||
const [ackTarget, setAckTarget] = useState<PreflightFinding | null>(null);
|
||||
const [ackReason, setAckReason] = useState('');
|
||||
const [ackExpiry, setAckExpiry] = useState<PreflightAckExpiryMode>('forever');
|
||||
const [ackSaving, setAckSaving] = useState(false);
|
||||
const [clearTarget, setClearTarget] = useState<PreflightFinding | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [ackSectionOpen, setAckSectionOpen] = useState(false);
|
||||
|
||||
const refreshReport = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/preflight`);
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to refresh the preflight report.');
|
||||
return;
|
||||
}
|
||||
setReport((await res.json()) as PreflightReport);
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
toast.error('Failed to refresh the preflight report.');
|
||||
}
|
||||
};
|
||||
|
||||
// Passive load of the last stored run when the stack or active node changes.
|
||||
// Read-only: opening the tab never renders or stores anything.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
@@ -131,7 +249,6 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId, reloadKey]);
|
||||
|
||||
// Running preflight renders the effective model and stores the result.
|
||||
const runPreflight = async () => {
|
||||
setRunning(true);
|
||||
try {
|
||||
@@ -149,13 +266,83 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const activeFindings = useMemo(
|
||||
() => report?.findings.filter(f => !f.acknowledged) ?? [],
|
||||
[report?.findings],
|
||||
);
|
||||
const acknowledgedFindings = useMemo(
|
||||
() => report?.findings.filter(f => f.acknowledged) ?? [],
|
||||
[report?.findings],
|
||||
);
|
||||
|
||||
const summary = report && report.status !== 'never-run' ? summaryMeta(report) : null;
|
||||
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;
|
||||
const { dismissed, dismiss } = usePreflightDismiss(stackName, nodeId, activeFindings);
|
||||
const hasActiveFindings = activeFindings.length > 0;
|
||||
|
||||
const openAckDialog = (finding: PreflightFinding) => {
|
||||
setAckTarget(finding);
|
||||
setAckReason('');
|
||||
setAckExpiry('forever');
|
||||
setAckOpen(true);
|
||||
};
|
||||
|
||||
const submitAck = async () => {
|
||||
if (!ackTarget) return;
|
||||
setAckSaving(true);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/preflight/acknowledgements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ruleId: ackTarget.ruleId,
|
||||
service: ackTarget.service ?? null,
|
||||
reason: ackReason.trim(),
|
||||
expiryMode: ackExpiry,
|
||||
expiresInDays: ackExpiry === 'days' ? 30 : undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({})) as { error?: string };
|
||||
toast.error(data.error ?? 'Failed to acknowledge the finding.');
|
||||
return;
|
||||
}
|
||||
setAckOpen(false);
|
||||
setAckTarget(null);
|
||||
await refreshReport();
|
||||
toast.success('Finding acknowledged.');
|
||||
} catch {
|
||||
toast.error('Failed to acknowledge the finding.');
|
||||
} finally {
|
||||
setAckSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmClear = async () => {
|
||||
if (!clearTarget?.acknowledgementId) return;
|
||||
setClearing(true);
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/stacks/${stackName}/preflight/acknowledgements/${clearTarget.acknowledgementId}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!res.ok && res.status !== 204) {
|
||||
toast.error('Failed to clear the acknowledgement.');
|
||||
return;
|
||||
}
|
||||
setClearTarget(null);
|
||||
await refreshReport();
|
||||
toast.success('Acknowledgement cleared.');
|
||||
} catch {
|
||||
toast.error('Failed to clear the acknowledgement.');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ackExpiryOptions = ackTarget ? expiryComboboxOptions(ackTarget) : [{ value: 'forever', label: EXPIRY_LABELS.forever }];
|
||||
|
||||
return (
|
||||
<div data-testid="preflight-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
@@ -198,8 +385,8 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
) : (
|
||||
<>
|
||||
{summary && SummaryIcon && !dismissed && (
|
||||
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone, 'relative')}>
|
||||
{hasFindings && (
|
||||
<div data-testid="preflight-status" data-status={report.activeStatus} className={cn(CARD_CLASS, summary.tone, 'relative')}>
|
||||
{hasActiveFindings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
@@ -225,19 +412,124 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
)}
|
||||
|
||||
{GROUP_ORDER.map(sev => {
|
||||
const items = report.findings.filter(f => f.severity === sev);
|
||||
const items = activeFindings.filter(f => f.severity === sev);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section key={sev}>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>{SEVERITY_META[sev].label} · {items.length}</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{items.map((f, i) => <FindingRow key={`${f.ruleId}-${f.service ?? ''}-${i}`} finding={f} />)}
|
||||
{items.map((f, i) => (
|
||||
<FindingRow
|
||||
key={`${f.ruleId}-${f.service ?? ''}-${i}`}
|
||||
finding={f}
|
||||
canEdit={canEdit}
|
||||
onAcknowledge={openAckDialog}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{acknowledgedFindings.length > 0 && (
|
||||
<section data-testid="preflight-acknowledged-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAckSectionOpen(v => !v)}
|
||||
className={cn(LABEL_CLASS, 'mb-1.5 inline-flex items-center gap-1 hover:text-brand')}
|
||||
>
|
||||
{ackSectionOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
acknowledged · {acknowledgedFindings.length}
|
||||
</button>
|
||||
{ackSectionOpen && (
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{acknowledgedFindings.map((f, i) => (
|
||||
<AcknowledgedRow
|
||||
key={`ack-${f.acknowledgementId ?? i}`}
|
||||
finding={f}
|
||||
canEdit={canEdit}
|
||||
onClear={setClearTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal open={ackOpen} onOpenChange={setAckOpen} size="md">
|
||||
<ModalHeader
|
||||
kicker="COMPOSE DOCTOR · ACKNOWLEDGE"
|
||||
title="Accept this finding"
|
||||
description="Accept a Compose Doctor finding for this stack."
|
||||
/>
|
||||
<ModalBody>
|
||||
{ackTarget && (
|
||||
<div className="rounded-md border border-card-border bg-card/40 px-3 py-2 font-mono text-[12px] text-foreground/80">
|
||||
{ackTarget.title}
|
||||
{ackTarget.service ? ` · ${ackTarget.service}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preflight-ack-reason" className={MODAL_FIELD_LABEL}>Note (optional)</Label>
|
||||
<textarea
|
||||
id="preflight-ack-reason"
|
||||
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50"
|
||||
placeholder="Why is this finding acceptable for this stack?"
|
||||
value={ackReason}
|
||||
onChange={(e) => setAckReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preflight-ack-expiry" className={MODAL_FIELD_LABEL}>Show again when</Label>
|
||||
<Combobox
|
||||
id="preflight-ack-expiry"
|
||||
options={ackExpiryOptions}
|
||||
value={ackExpiry}
|
||||
onValueChange={(value) => setAckExpiry(value as PreflightAckExpiryMode)}
|
||||
placeholder="Select expiry"
|
||||
disabled={ackSaving}
|
||||
className="w-full"
|
||||
/>
|
||||
{ackExpiry === 'until_image_change' && (
|
||||
<p className="text-[11px] leading-relaxed text-stat-subtitle">
|
||||
Re-surfaces when the service image reference changes in the effective model, not on silent digest re-pulls.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
hint="SHOW AGAIN"
|
||||
hintAccent={EXPIRY_LABELS[ackExpiry]}
|
||||
secondary={(
|
||||
<Button variant="outline" size="sm" onClick={() => setAckOpen(false)} disabled={ackSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
primary={(
|
||||
<Button size="sm" onClick={submitAck} disabled={ackSaving}>
|
||||
{ackSaving ? 'Saving…' : 'Acknowledge'}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={clearTarget !== null}
|
||||
onOpenChange={(open) => { if (!open) setClearTarget(null); }}
|
||||
kicker="COMPOSE DOCTOR · CLEAR"
|
||||
title="Clear acknowledgement"
|
||||
description="Clear a Compose Doctor acknowledgement for this stack."
|
||||
hint="RESTORES active finding"
|
||||
confirmLabel="Clear"
|
||||
confirming={clearing}
|
||||
onConfirm={confirmClear}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
This finding will count as active again on the next preflight read.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates';
|
||||
|
||||
const IMAGE_UPDATE_POLL_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -10,55 +11,128 @@ const IMAGE_UPDATE_POLL_MS = 5 * 60 * 1000;
|
||||
* `refresh()` to force a refetch (e.g. after a deploy or a manual
|
||||
* registry-check trigger).
|
||||
*
|
||||
* Extracted from EditorLayout so the polling lifecycle and its state
|
||||
* live next to each other instead of being spread across a 3000-line
|
||||
* component. The dependency on `apiFetch` keeps the call routed
|
||||
* through the active-node header just like before.
|
||||
* Also owns the sidebar-indicator toggle preference, fetched from
|
||||
* /api/image-updates/status on the same cadence. All requests are
|
||||
* pinned to the captured node so a mid-flight node switch never
|
||||
* writes stale data.
|
||||
*/
|
||||
export function useImageUpdates(activeNodeId: number | undefined) {
|
||||
const [stackUpdates, setStackUpdates] = useState<Record<string, StackUpdateInfo>>({});
|
||||
const [sidebarIndicators, setSidebarIndicators] = useState(false);
|
||||
|
||||
// Track which node owns the current state. When activeNodeId changes
|
||||
// React renders once with the old owner before the passive effect clears
|
||||
// the data. Returning empty defaults when the IDs mismatch prevents a
|
||||
// single-frame flash of the wrong node's data.
|
||||
const [ownerNodeId, setOwnerNodeId] = useState<number | undefined>(activeNodeId);
|
||||
|
||||
// Generation counter: every activeNodeId change increments it, and every
|
||||
// await is gated against it so a slow response from a previous node is
|
||||
// discarded.
|
||||
const genRef = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/detail');
|
||||
if (res.ok) {
|
||||
setStackUpdates(await res.json() as Record<string, StackUpdateInfo>);
|
||||
return;
|
||||
}
|
||||
// A remote node on an older Sencho lacks /detail; fall back to the boolean
|
||||
// map so update badges keep working until that node is upgraded.
|
||||
if (res.status === 404) {
|
||||
const boolRes = await apiFetch('/image-updates');
|
||||
if (boolRes.ok) {
|
||||
const bool = await boolRes.json() as Record<string, boolean>;
|
||||
const synthesized: Record<string, StackUpdateInfo> = {};
|
||||
for (const [stack, hasUpdate] of Object.entries(bool)) {
|
||||
synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 };
|
||||
}
|
||||
setStackUpdates(synthesized);
|
||||
const gen = ++genRef.current;
|
||||
const targetNodeId = activeNodeId ?? null;
|
||||
|
||||
// Self-contained status helper: owns fetch, parse, and state write.
|
||||
// A failure here never blocks the detail path below.
|
||||
const fetchStatus = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/status', { nodeId: targetNodeId });
|
||||
if (genRef.current !== gen) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json() as ImageUpdateStatus;
|
||||
if (genRef.current !== gen) return;
|
||||
setSidebarIndicators(data.sidebarIndicators ?? false);
|
||||
} else {
|
||||
console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status);
|
||||
console.error('[ImageUpdates] status fetch returned', res.status);
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error('[ImageUpdates] status fetch failed:', e);
|
||||
}
|
||||
// Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep
|
||||
// the last-known state on screen, but do not let the failure go silent.
|
||||
console.error('[ImageUpdates] /image-updates/detail returned', res.status);
|
||||
} catch (e: unknown) {
|
||||
console.error('[ImageUpdates] fetch failed:', e);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Self-contained detail helper: owns fetch, parse, 404 fallback, and
|
||||
// state write. A failure here never blocks the status path above.
|
||||
const fetchDetail = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/detail', { nodeId: targetNodeId });
|
||||
if (genRef.current !== gen) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json() as Record<string, StackUpdateInfo>;
|
||||
if (genRef.current !== gen) return;
|
||||
setStackUpdates(data);
|
||||
return;
|
||||
}
|
||||
// A remote node on an older Sencho lacks /detail; fall back to the boolean
|
||||
// map so update badges keep working until that node is upgraded.
|
||||
if (res.status === 404) {
|
||||
const boolRes = await apiFetch('/image-updates', { nodeId: targetNodeId });
|
||||
if (genRef.current !== gen) return;
|
||||
if (boolRes.ok) {
|
||||
const bool = await boolRes.json() as Record<string, boolean>;
|
||||
if (genRef.current !== gen) return;
|
||||
const synthesized: Record<string, StackUpdateInfo> = {};
|
||||
for (const [stack, hasUpdate] of Object.entries(bool)) {
|
||||
synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 };
|
||||
}
|
||||
setStackUpdates(synthesized);
|
||||
} else {
|
||||
console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep
|
||||
// the last-known state on screen, but do not let the failure go silent.
|
||||
console.error('[ImageUpdates] /image-updates/detail returned', res.status);
|
||||
} catch (e: unknown) {
|
||||
console.error('[ImageUpdates] fetch failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.allSettled([fetchStatus(), fetchDetail()]);
|
||||
}, [activeNodeId]);
|
||||
|
||||
// Pin the interval to the latest closure without retriggering it on
|
||||
// every render the way putting `refresh` into the deps array would.
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
|
||||
// Poll on mount and on node change. Reset state and capture the owning
|
||||
// node BEFORE fetching so the old node's data is cleared before the new
|
||||
// node's first response arrives, and the guard above returns empty defaults
|
||||
// on the render before this effect fires.
|
||||
useEffect(() => {
|
||||
genRef.current += 1;
|
||||
setStackUpdates({}); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setSidebarIndicators(false); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setOwnerNodeId(activeNodeId); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
void refreshRef.current();
|
||||
const id = setInterval(() => { void refreshRef.current(); }, IMAGE_UPDATE_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [activeNodeId]);
|
||||
|
||||
return { stackUpdates, refresh };
|
||||
// React to settings changes so toggling the sidebar-indicator preference
|
||||
// propagates immediately without waiting for the 5-minute poll.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ changedKeys?: string[] }>).detail;
|
||||
if (detail?.changedKeys?.includes('image_update_sidebar_indicators')) {
|
||||
refreshRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, handler);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler);
|
||||
}, []);
|
||||
|
||||
// Return empty defaults until the owning node matches the active node.
|
||||
// This prevents React from rendering node B with node A's update data and
|
||||
// sidebar preference during the single frame before the passive effect fires.
|
||||
const isOwner = activeNodeId !== undefined && activeNodeId === ownerNodeId;
|
||||
return {
|
||||
stackUpdates: isOwner ? stackUpdates : {} as Record<string, StackUpdateInfo>,
|
||||
refresh,
|
||||
sidebarIndicators: isOwner ? sidebarIndicators : false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ImageUpdateStatus {
|
||||
mode: 'interval' | 'cron';
|
||||
/** 5-field cron expression when mode is 'cron', null otherwise. */
|
||||
cronExpression: string | null;
|
||||
/** Whether sidebar update-status indicators are enabled. Optional for older-node compatibility. */
|
||||
sidebarIndicators?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user