Files
sencho/frontend/src/components/FleetView.tsx
T
Anso 5fe0843eb2 feat: add routable browser URLs for stacks and shell views (#1586)
* feat: add routable browser URLs for stacks and shell views

Sync in-memory navigation to the address bar via a History API hook so
deep links, refresh, Back/Forward, and bookmarks work across nodes,
views, stack editor tabs, and mobile surfaces. Gate role/tier URL
normalization on permissions and license readiness, preserve URLs on
metadata fetch failure, and surface retryable stack-list errors without
rewriting pending stack paths.

* fix: preserve deep-link views on cold load and refresh

Stop the node-switch effect from resetting to dashboard on initial mount.

Defer URL writer settlement until hydrated activeView matches the route.

Adds E2E coverage for shell cold loads, stack refresh, and compose env tab.

* fix: keep mobile dashboard on list surface so sidebar renders

On mobile, the URL sync hook was routing /nodes/<slug>/dashboard to the
content surface, hiding the stack list sidebar. This prevented the
data-stacks-loaded sentinel from appearing, causing sidebar truncation
E2E tests to time out after reload on a mobile viewport.

Mobile dashboard now stays on the list surface; other non-editor views
still render on the content surface.

* fix: complete mobile URL routing follow-ups for stack deep links

Restore mobile /dashboard vs /stacks, list surface always writes /stacks.

Hydrate pendingDetailStack, freeze compose failures with routeDetailError,

and add unit plus E2E coverage.

* fix: hydrate shell views from URL and sync in-app navigation

Bootstrap activeView and tab state from the pathname on cold load.

Settle route phase when state already matches, normalize unknown segments,

and open Monaco editor tabs from stack deep links via applyEditorRouteState.

* fix: prevent mobile stack deep links from hanging on cold load

The resolvePendingStack effect did not re-fire when the pending stack ref
was populated during URL hydration, because the urlHydratingStack state
set in the same callback was not listed in the effect's dependency array.
Adding it causes the effect to retry once hydration has committed.

A resolvingRef mutex prevents concurrent invocations. When the target file
is already loaded, route state is applied directly without calling
loadFileForRoute, which avoids unmounting the editor (and hiding the
recovery chip) if a background refresh triggers route resolution during
a deploy operation.

* test: adapt stack, deploy, and sidebar e2e specs to routable stack URLs

* ci: raise E2E Playwright job timeout to 20 minutes
2026-07-08 13:07:16 -04:00

339 lines
17 KiB
TypeScript

import { useState, useEffect } from 'react';
import {
RefreshCw, Camera, FileDown,
Network, SlidersHorizontal,
Send, KeyRound, ArrowLeftRight, Wrench, Workflow, Tag,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { FleetMasthead } from './fleet/FleetMasthead';
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet';
import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog';
import { OverviewTab } from './FleetView/OverviewTab';
import { useFleetPreferences } from './FleetView/hooks/useFleetPreferences';
import { useFleetUpdateStatus } from './FleetView/hooks/useFleetUpdateStatus';
import { useFleetPolling } from './FleetView/hooks/useFleetPolling';
import { useFleetOverview } from './FleetView/hooks/useFleetOverview';
import { useFleetDossierExport } from './FleetView/hooks/useFleetDossierExport';
import { useTopologyPreferences } from '@/hooks/useTopologyPreferences';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { springs } from '@/lib/motion';
import { useLicense } from '@/context/LicenseContext';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { PaidGate } from './PaidGate';
import FleetSnapshots from './FleetSnapshots';
import { FleetConfiguration } from './fleet/FleetConfiguration';
import { RoutingTab } from './fleet/RoutingTab';
import { FederationTab } from './fleet/FederationTab';
import { DeploymentsTab } from './blueprints/DeploymentsTab';
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
import { SecretsTab } from './fleet/secrets/SecretsTab';
import { DependencyMapTab } from './fleet/DependencyMapTab';
import { ContainerLabelsTab } from './fleet/ContainerLabelsTab';
import { useNodeActions } from './nodes/useNodeActions';
import type { FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { MuteRuleDraft } from '@/lib/muteRules';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
/** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
onOpenSettingsSection?: (section: SectionId) => void;
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
onFleetUpdatesIntentConsumed?: () => void;
/** Controlled fleet sub-tab (shell-owned for URL sync). */
fleetActiveTab?: FleetTab;
onFleetActiveTabChange?: (tab: FleetTab) => void;
}
export function FleetView({
onNavigateToNode,
onOpenSettingsSection,
onOpenMuteRulesWithPrefill,
fleetUpdatesIntent,
onFleetUpdatesIntentConsumed,
fleetActiveTab: controlledTab,
onFleetActiveTabChange,
}: FleetViewProps) {
const { isPaid } = useLicense();
const { isAdmin } = useAuth();
const { hasCapability } = useNodes();
const containerLabelsEnabled = hasCapability('container-label-inventory');
const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus();
const overview = useFleetOverview({ prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses });
// The local node's status backs the confirm dialog copy (pin + target ref).
const localUpdateConfirmStatus = updateStatus.localUpdateConfirm !== null
? updateStatus.updateStatuses.find(s => s.nodeId === updateStatus.localUpdateConfirm)
: undefined;
const topology = useTopologyPreferences();
const { exporting, exportDossier } = useFleetDossierExport();
useFleetPolling({
fetchOverview: overview.fetchOverview,
fetchUpdateStatus: updateStatus.fetchUpdateStatus,
updateStatuses: updateStatus.updateStatuses,
});
const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes');
const [internalTab, setInternalTab] = useState<FleetTab>('overview');
const activeTab = controlledTab ?? internalTab;
const setActiveTab = (tab: FleetTab) => {
onFleetActiveTabChange?.(tab);
if (controlledTab === undefined) setInternalTab(tab);
};
useEffect(() => {
if (fleetUpdatesIntent) {
setInitialUpdatesTab(fleetUpdatesIntent.tab);
updateStatus.setShowUpdateModal(true);
updateStatus.fetchUpdateStatus();
onFleetUpdatesIntentConsumed?.();
}
}, [fleetUpdatesIntent, updateStatus, onFleetUpdatesIntentConsumed]);
const { mastheadStats, lastSyncAt, loading, refreshing } = overview;
const { openEdit, openDelete, NodeActionModals } = useNodeActions({
onNodeChange: () => { void overview.fetchOverview(true); },
});
return (
<div className="h-full overflow-auto p-6">
<FleetMasthead
nodeCount={mastheadStats.nodeCount}
onlineCount={mastheadStats.onlineCount}
criticalCount={mastheadStats.criticalCount}
totalCpuPercent={mastheadStats.avgCpuNum}
worstCpu={mastheadStats.worstCpu}
totalMemUsed={mastheadStats.totalMemUsed}
totalMemTotal={mastheadStats.totalMemTotal}
activeContainers={mastheadStats.totalContainers}
totalContainers={mastheadStats.totalContainersAll}
lastSyncAt={lastSyncAt}
loading={loading}
/>
<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">
{/* Flatten the list's own pill band so the tabs sit directly in
the single full-width band, not a nested second band. */}
<TabsList className="border-transparent bg-transparent max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
<TabsHighlightItem value="overview">
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsHighlightItem>
{isAdmin && (
<TabsHighlightItem value="snapshots">
<TabsTrigger value="snapshots">
<Camera className="w-4 h-4 mr-1.5" />Snapshots
</TabsTrigger>
</TabsHighlightItem>
)}
<TabsHighlightItem value="configuration">
<TabsTrigger value="configuration">
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="dependencies">
<TabsTrigger value="dependencies">
<Workflow className="w-4 h-4 mr-1.5" />Map
</TabsTrigger>
</TabsHighlightItem>
{containerLabelsEnabled && (
<TabsHighlightItem value="container-labels">
<TabsTrigger value="container-labels">
<Tag className="w-4 h-4 mr-1.5" />Docker Labels
</TabsTrigger>
</TabsHighlightItem>
)}
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
<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">
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Routing
</TabsTrigger>
</TabsHighlightItem>
)}
<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
</TabsTrigger>
</TabsHighlightItem>
{isPaid && isAdmin && (
<TabsHighlightItem value="secrets">
<TabsTrigger value="secrets">
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
</TabsTrigger>
</TabsHighlightItem>
)}
</TabsHighlight>
</TabsList>
<div className="flex items-center gap-2 shrink-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => overview.fetchOverview(true)}
disabled={refreshing}
className="h-9 w-9 p-0"
aria-label="Refresh"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</TooltipProvider>
{isAdmin && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => { void exportDossier(); }}
disabled={exporting}
className="h-9 w-9 p-0"
aria-label="Export Dossier"
>
<FileDown className={`w-4 h-4 ${exporting ? 'animate-pulse' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Export Dossier</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
<TabsContent value="overview">
<OverviewTab
loading={loading}
nodes={overview.nodes}
processedNodes={overview.processedNodes}
allNodes={overview.allNodes}
topologyNodes={overview.topologyNodes}
viewMode={overview.viewMode}
onViewModeChange={overview.setViewMode}
searchQuery={overview.searchQuery}
onSearchQueryChange={overview.setSearchQuery}
prefs={prefs}
onPrefsChange={updatePrefs}
fleetPalette={overview.fleetPalette}
labelFilters={overview.labelFilters}
onLabelFiltersChange={overview.setLabelFilters}
onClearFilters={overview.clearFilters}
fleetStackLabelMap={overview.fleetStackLabelMap}
updateStatusMap={overview.updateStatusMap}
onNavigateToNode={onNavigateToNode}
onUpdate={updateStatus.triggerNodeUpdate}
updatingNodeId={updateStatus.updatingNodeId}
onRetryUpdate={updateStatus.retryNodeUpdate}
onDismissUpdate={updateStatus.dismissNodeUpdate}
onCordonChange={() => { void overview.fetchOverview(true); }}
onEditNode={isAdmin ? openEdit : undefined}
onDeleteNode={isAdmin ? openDelete : undefined}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
onCheckUpdates={updateStatus.checkUpdates}
checkingUpdates={updateStatus.checkingUpdates}
topologyMode={topology.prefs.mode}
onTopologyModeChange={topology.setMode}
topologyPositions={topology.prefs.positions}
onTopologyPositionsChange={topology.setPositions}
/>
</TabsContent>
{isAdmin && (
<TabsContent value="snapshots">
<FleetSnapshots />
</TabsContent>
)}
<TabsContent value="configuration">
<FleetConfiguration />
</TabsContent>
<TabsContent value="dependencies">
<DependencyMapTab />
</TabsContent>
{containerLabelsEnabled && (
<TabsContent value="container-labels">
<ContainerLabelsTab onNavigateToNode={onNavigateToNode} />
</TabsContent>
)}
<TabsContent value="deployments">
<DeploymentsTab />
</TabsContent>
{isPaid && (
<TabsContent value="routing">
<PaidGate>
<RoutingTab 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. */}
<FleetActionsTab nodes={overview.nodes} />
</TabsContent>
{isPaid && isAdmin && (
<TabsContent value="secrets">
<SecretsTab />
</TabsContent>
)}
</Tabs>
{updateStatus.reconnecting && (
<ReconnectingOverlay preUpdateStartedAt={updateStatus.preUpdateStartedAt} />
)}
<NodeUpdatesSheet
open={updateStatus.showUpdateModal}
onOpenChange={updateStatus.setShowUpdateModal}
checkingUpdates={updateStatus.checkingUpdates}
updateStatuses={updateStatus.updateStatuses}
updatingNodeId={updateStatus.updatingNodeId}
isAdmin={isAdmin}
initialTab={initialUpdatesTab}
fetchUpdateStatus={updateStatus.fetchUpdateStatus}
triggerNodeUpdate={updateStatus.triggerNodeUpdate}
retryNodeUpdate={updateStatus.retryNodeUpdate}
dismissNodeUpdate={updateStatus.dismissNodeUpdate}
triggerUpdateAll={updateStatus.triggerUpdateAll}
/>
<LocalUpdateConfirmDialog
open={updateStatus.localUpdateConfirm !== null}
onOpenChange={(open) => { if (!open) updateStatus.setLocalUpdateConfirm(null); }}
onConfirm={updateStatus.confirmLocalUpdate}
imagePinKind={localUpdateConfirmStatus?.imagePinKind}
composeImageRef={localUpdateConfirmStatus?.composeImageRef}
targetImageRef={localUpdateConfirmStatus?.targetImageRef}
targetVersion={localUpdateConfirmStatus?.latestVersion}
/>
{NodeActionModals}
</div>
);
}