Files
sencho/frontend/src/components/FleetView.tsx
T

246 lines
12 KiB
TypeScript

import { useExperimental } from '@/hooks/useExperimental';
import {
RefreshCw, Search, Camera,
Network, SlidersHorizontal,
Send, KeyRound, ArrowLeftRight,
} from 'lucide-react';
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 { 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 { AdmiralGate } from './AdmiralGate';
import FleetSnapshots from './FleetSnapshots';
import { FleetConfiguration } from './fleet/FleetConfiguration';
import { FleetSoonPlaceholder, SoonBadge } from './fleet/FleetSoonPlaceholder';
import { RoutingTab } from './fleet/RoutingTab';
import { DeploymentsTab } from './blueprints/DeploymentsTab';
interface FleetViewProps {
onNavigateToNode: (nodeId: number, stackName: string) => void;
}
export function FleetView({ onNavigateToNode }: FleetViewProps) {
const { isPaid, license } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral';
const experimental = useExperimental();
const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus({ isPaid });
const overview = useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses });
useFleetPolling({
isPaid,
fetchOverview: overview.fetchOverview,
fetchUpdateStatus: updateStatus.fetchUpdateStatus,
updateStatuses: updateStatus.updateStatuses,
});
const { mastheadStats, lastSyncAt, loading, refreshing } = overview;
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 defaultValue="overview">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<TabsList>
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="overview">
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsHighlightItem>
{isPaid && (
<TabsHighlightItem value="snapshots">
<TabsTrigger value="snapshots">
<Camera className="w-4 h-4 mr-1.5" />Snapshots
</TabsTrigger>
</TabsHighlightItem>
)}
{isAdmiral && experimental && (
<TabsHighlightItem value="routing">
<TabsTrigger value="routing">
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Traffic · Routing
</TabsTrigger>
</TabsHighlightItem>
)}
<TabsHighlightItem value="configuration">
<TabsTrigger value="configuration">
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
</TabsTrigger>
</TabsHighlightItem>
{experimental && (
<>
<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
{!isPaid && <SoonBadge />}
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="federation">
<TabsTrigger value="federation">
<Network className="w-4 h-4 mr-1.5" />Federation
<SoonBadge />
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="secrets">
<TabsTrigger value="secrets">
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
<SoonBadge />
</TabsTrigger>
</TabsHighlightItem>
</>
)}
</TabsHighlight>
</TabsList>
<div className="flex items-center gap-2">
{isPaid && (
<Button
variant="outline"
size="sm"
onClick={updateStatus.checkUpdates}
className="gap-2"
>
<Search className="w-4 h-4" />
Check Updates
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => overview.fetchOverview(true)}
disabled={refreshing}
className="gap-2"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</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}
isPaid={isPaid}
fleetStackLabelMap={overview.fleetStackLabelMap}
updateStatusMap={overview.updateStatusMap}
onNavigateToNode={onNavigateToNode}
onUpdate={isPaid ? updateStatus.triggerNodeUpdate : undefined}
updatingNodeId={updateStatus.updatingNodeId}
onRetryUpdate={isPaid ? updateStatus.retryNodeUpdate : undefined}
onDismissUpdate={isPaid ? updateStatus.dismissNodeUpdate : undefined}
/>
</TabsContent>
{isPaid && (
<TabsContent value="snapshots">
<FleetSnapshots />
</TabsContent>
)}
{isAdmiral && experimental && (
<TabsContent value="routing">
<AdmiralGate>
<RoutingTab />
</AdmiralGate>
</TabsContent>
)}
<TabsContent value="configuration">
<FleetConfiguration />
</TabsContent>
{experimental && (
<>
<TabsContent value="deployments">
{isPaid ? (
<DeploymentsTab />
) : (
<FleetSoonPlaceholder
icon={<Send className="h-4 w-4" />}
kicker="Deployments · Blueprints"
title="Declare once. Distribute everywhere."
description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional."
plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']}
/>
)}
</TabsContent>
<TabsContent value="federation">
<FleetSoonPlaceholder
icon={<Network className="h-4 w-4" />}
kicker="Federation · Coming soon"
title="The fleet as one logical surface"
description="Pin policies, drain a node for maintenance, weight-aware scheduling. This stack runs on whichever node has capacity."
plannedActions={['Pin policy', 'Drain node', 'Cordon', 'Capacity plan']}
/>
</TabsContent>
<TabsContent value="secrets">
<FleetSoonPlaceholder
icon={<KeyRound className="h-4 w-4" />}
kicker="Secrets · Coming soon"
title="One source of truth for env, creds and certs"
description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies."
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
/>
</TabsContent>
</>
)}
</Tabs>
{updateStatus.reconnecting && (
<ReconnectingOverlay preUpdateStartedAt={updateStatus.preUpdateStartedAt} />
)}
<NodeUpdatesSheet
open={updateStatus.showUpdateModal}
onOpenChange={updateStatus.setShowUpdateModal}
checkingUpdates={updateStatus.checkingUpdates}
updateStatuses={updateStatus.updateStatuses}
updatingNodeId={updateStatus.updatingNodeId}
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}
/>
</div>
);
}