From 37af4a208762b844bb71ff265f75fe708714aca7 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 4 Jun 2026 21:46:33 -0400 Subject: [PATCH] refactor(fleet): standardize empty-state and header layout across Fleet tabs (#1312) Extract the Secrets tab's header and empty-card markup into shared primitives (FleetTabHeading, FleetEmptyState, FleetEmptyCard) and adopt them across Snapshots, Deployments, Routing, Federation, and Secrets so the Fleet area presents one consistent layout. The empty card sits vertically centered below a title/subtitle header. Deployments keeps its onboarding steps inside the shared shell; Federation's sections are unchanged. --- frontend/src/components/FleetSnapshots.tsx | 75 ++++++++++--------- .../components/blueprints/DeploymentsTab.tsx | 19 ++++- .../src/components/fleet/FederationTab.tsx | 19 ++--- .../src/components/fleet/FleetEmptyState.tsx | 61 +++++++++++++++ frontend/src/components/fleet/RoutingTab.tsx | 19 +++-- .../components/fleet/secrets/SecretsTab.tsx | 49 ++++++------ 6 files changed, 160 insertions(+), 82 deletions(-) create mode 100644 frontend/src/components/fleet/FleetEmptyState.tsx diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index a963416e..309996d9 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -18,6 +18,7 @@ import { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { toast } from '@/components/ui/toast-store'; +import { FleetTabHeading, FleetEmptyState, FleetEmptyCard } from './fleet/FleetEmptyState'; // --- Types --- @@ -595,34 +596,33 @@ export default function FleetSnapshots() { return (
- {/* Header */} -
-
- -

Fleet Snapshots

-
-
- {needsPagination && ( -
- + + {safePage + 1} / {totalPages} + + +
+ )} + {isAdmin && !showCreateForm && ( + - - {safePage + 1} / {totalPages} - - -
- )} - {isAdmin && !showCreateForm && ( - - )} -
-
+ )} + + } + /> {/* Create form */} {showCreateForm && ( @@ -666,14 +666,19 @@ export default function FleetSnapshots() { ) : snapshots.length === 0 ? ( - /* Empty state */ -
- -

No snapshots yet

-

- Create your first fleet snapshot to back up compose files across all nodes. -

-
+ + setShowCreateForm(true)}> + + Create Snapshot + + ) : undefined} + /> + ) : ( /* Snapshots table */
diff --git a/frontend/src/components/blueprints/DeploymentsTab.tsx b/frontend/src/components/blueprints/DeploymentsTab.tsx index 8092d3cb..22a07cfe 100644 --- a/frontend/src/components/blueprints/DeploymentsTab.tsx +++ b/frontend/src/components/blueprints/DeploymentsTab.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; +import { Plus } from 'lucide-react'; import { Modal, ModalHeader, ModalBody } from '@/components/ui/modal'; +import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui/toast-store'; import { type BlueprintListItem, @@ -11,6 +13,7 @@ import { } from '@/lib/blueprintsApi'; import { BlueprintCatalog } from './BlueprintCatalog'; import { BlueprintEmptyState } from './BlueprintEmptyState'; +import { FleetTabHeading, FleetEmptyState } from '../fleet/FleetEmptyState'; import { BlueprintDetail } from './BlueprintDetail'; import { BlueprintEditor } from './BlueprintEditor'; import { useLicense } from '@/context/LicenseContext'; @@ -95,7 +98,21 @@ export function DeploymentsTab() { return (
{blueprints.length === 0 ? ( - setCreateOpen(true)} canCreate={canEdit} /> + <> + setCreateOpen(true)}> + + New Blueprint + + ) : undefined} + /> + + setCreateOpen(true)} canCreate={canEdit} /> + + ) : ( -
-
- -
-
-
Federation
-

Placement control

-

- Mark nodes unschedulable and pin blueprints to specific nodes. Sencho proposes placements; you approve them. Existing deployments are never moved without an explicit operator action. -

-
-
+
diff --git a/frontend/src/components/fleet/FleetEmptyState.tsx b/frontend/src/components/fleet/FleetEmptyState.tsx new file mode 100644 index 00000000..78b8be02 --- /dev/null +++ b/frontend/src/components/fleet/FleetEmptyState.tsx @@ -0,0 +1,61 @@ +import type { LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface FleetTabHeadingProps { + title: string; + subtitle: string; + action?: ReactNode; +} + +/** + * Standardized Fleet tab header: italic-serif title and muted subtitle on the + * left, an optional primary action on the right. Rendered in both empty and + * populated states so the tab chrome stays consistent. + */ +export function FleetTabHeading({ title, subtitle, action }: FleetTabHeadingProps) { + return ( +
+
+

{title}

+

{subtitle}

+
+ {action} +
+ ); +} + +/** + * Vertical-centering shell that floats an empty-state card in the middle of the + * tab body, below the heading. + */ +export function FleetEmptyState({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +interface FleetEmptyCardProps { + icon: LucideIcon; + title: string; + description: string; + action?: ReactNode; +} + +/** + * Minimal empty-state card: centered icon, italic headline, muted one-line + * description, optional CTA. + */ +export function FleetEmptyCard({ icon: Icon, title, description, action }: FleetEmptyCardProps) { + return ( +
+ +
+

{title}

+

{description}

+
+ {action} +
+ ); +} diff --git a/frontend/src/components/fleet/RoutingTab.tsx b/frontend/src/components/fleet/RoutingTab.tsx index bcf87e1f..25a6c877 100644 --- a/frontend/src/components/fleet/RoutingTab.tsx +++ b/frontend/src/components/fleet/RoutingTab.tsx @@ -12,6 +12,7 @@ import { MeshDiagnosticsSheet } from './MeshDiagnosticsSheet'; import { MeshActivitySheet } from './MeshActivitySheet'; import { MeshTopologyGraph, type MeshGraphEdgeMode } from './MeshTopologyGraph'; import { SegmentedControl } from '@/components/ui/segmented-control'; +import { FleetTabHeading, FleetEmptyState, FleetEmptyCard } from './FleetEmptyState'; import type { MeshAlias, MeshDataPlaneStatus, MeshNodeStatus, MeshProbeResult } from '@/types/mesh'; type RoutingViewMode = 'table' | 'graph'; @@ -137,12 +138,18 @@ export function RoutingTab({ canManage }: { canManage: boolean }) { if (status.length === 0) { return ( -
- -
No nodes available
-
- Add a node to the fleet to start routing traffic between containers across nodes. -
+
+ + + +
); } diff --git a/frontend/src/components/fleet/secrets/SecretsTab.tsx b/frontend/src/components/fleet/secrets/SecretsTab.tsx index 5bec6986..1fee33e9 100644 --- a/frontend/src/components/fleet/secrets/SecretsTab.tsx +++ b/frontend/src/components/fleet/secrets/SecretsTab.tsx @@ -5,6 +5,7 @@ import { toast } from '@/components/ui/toast-store'; import { listSecrets, deleteSecret, type SecretSummary } from '@/lib/secretsApi'; import { SecretBundleSheet } from './SecretBundleSheet'; import { SecretPushSheet } from './SecretPushSheet'; +import { FleetTabHeading, FleetEmptyState, FleetEmptyCard } from '../FleetEmptyState'; export function SecretsTab() { const [items, setItems] = useState([]); @@ -69,18 +70,29 @@ export function SecretsTab() { return (
-
-
-

Secret bundles

-

Centralized env-var bundles, encrypted at rest, versioned, pushed to labeled nodes.

-
- -
+ setCreating(true)} className="gap-1.5"> + New bundle + + } + /> {items.length === 0 ? ( - setCreating(true)} /> + + setCreating(true)} className="gap-1.5"> + Create your first bundle + + } + /> + ) : (
@@ -149,20 +161,3 @@ export function SecretsTab() { ); } - -function EmptyState({ onCreate }: { onCreate: () => void }) { - return ( -
- -
-

One source of truth for env

-

- Build a bundle of key=value pairs, push it to nodes by label, see exactly what changed before you write. -

-
- -
- ); -}