mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
337 lines
17 KiB
TypeScript
337 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;
|
|
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
|
|
fleetTab?: FleetTab | null;
|
|
onFleetTabConsumed?: () => void;
|
|
}
|
|
|
|
export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: 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 });
|
|
const topology = useTopologyPreferences();
|
|
const { exporting, exportDossier } = useFleetDossierExport();
|
|
|
|
useFleetPolling({
|
|
fetchOverview: overview.fetchOverview,
|
|
fetchUpdateStatus: updateStatus.fetchUpdateStatus,
|
|
updateStatuses: updateStatus.updateStatuses,
|
|
});
|
|
|
|
const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes');
|
|
|
|
// Controlled tab value so a deep-link (e.g. Snapshots from the stack storage
|
|
// warning) can land on the right tab.
|
|
const [activeTab, setActiveTab] = useState<FleetTab>('overview');
|
|
|
|
useEffect(() => {
|
|
if (fleetUpdatesIntent) {
|
|
setInitialUpdatesTab(fleetUpdatesIntent.tab);
|
|
updateStatus.setShowUpdateModal(true);
|
|
updateStatus.fetchUpdateStatus();
|
|
onFleetUpdatesIntentConsumed?.();
|
|
}
|
|
}, [fleetUpdatesIntent, updateStatus, onFleetUpdatesIntentConsumed]);
|
|
|
|
useEffect(() => {
|
|
if (fleetTab) {
|
|
setActiveTab(fleetTab);
|
|
onFleetTabConsumed?.();
|
|
}
|
|
}, [fleetTab, onFleetTabConsumed]);
|
|
|
|
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" />
|
|
{isPaid && (
|
|
<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>
|
|
)}
|
|
{isPaid && (
|
|
<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>
|
|
)}
|
|
{isPaid && (
|
|
<TabsContent value="deployments">
|
|
<DeploymentsTab />
|
|
</TabsContent>
|
|
)}
|
|
{isPaid && (
|
|
<TabsContent value="routing">
|
|
<PaidGate>
|
|
<RoutingTab canManage={isAdmin} />
|
|
</PaidGate>
|
|
</TabsContent>
|
|
)}
|
|
{isPaid && (
|
|
<TabsContent value="federation">
|
|
<PaidGate>
|
|
<FederationTab canManage={isAdmin} />
|
|
</PaidGate>
|
|
</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}
|
|
/>
|
|
|
|
{NodeActionModals}
|
|
</div>
|
|
);
|
|
}
|