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.
This commit is contained in:
Anso
2026-06-04 21:46:33 -04:00
committed by GitHub
parent 81858a0bb0
commit 37af4a2087
6 changed files with 160 additions and 82 deletions
+40 -35
View File
@@ -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 (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<Camera className="w-5 h-5 text-muted-foreground" strokeWidth={1.5} />
<h2 className="text-lg font-semibold">Fleet Snapshots</h2>
</div>
<div className="flex items-center gap-2">
{needsPagination && (
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage === 0} onClick={() => setPage(safePage - 1)}>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
<FleetTabHeading
title="Fleet Snapshots"
subtitle="Point-in-time compose backups across every node."
action={
<div className="flex items-center gap-2">
{needsPagination && (
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage === 0} onClick={() => setPage(safePage - 1)}>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage >= totalPages - 1} onClick={() => setPage(safePage + 1)}>
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
{isAdmin && !showCreateForm && (
<Button size="sm" className="gap-1.5" onClick={() => setShowCreateForm(true)}>
<Plus className="w-4 h-4" strokeWidth={1.5} />
Create Snapshot
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage >= totalPages - 1} onClick={() => setPage(safePage + 1)}>
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
{isAdmin && !showCreateForm && (
<Button size="sm" className="gap-1.5" onClick={() => setShowCreateForm(true)}>
<Plus className="w-4 h-4" strokeWidth={1.5} />
Create Snapshot
</Button>
)}
</div>
</div>
)}
</div>
}
/>
{/* Create form */}
{showCreateForm && (
@@ -666,14 +666,19 @@ export default function FleetSnapshots() {
</div>
</div>
) : snapshots.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center py-16 text-center">
<Camera className="w-12 h-12 text-muted-foreground/50 mb-4" />
<h3 className="text-sm font-medium mb-1">No snapshots yet</h3>
<p className="text-xs text-muted-foreground max-w-sm">
Create your first fleet snapshot to back up compose files across all nodes.
</p>
</div>
<FleetEmptyState>
<FleetEmptyCard
icon={Camera}
title="No snapshots yet"
description="Create your first fleet snapshot to back up compose files across all nodes."
action={isAdmin && !showCreateForm ? (
<Button size="sm" className="gap-1.5" onClick={() => setShowCreateForm(true)}>
<Plus className="w-4 h-4" strokeWidth={1.5} />
Create Snapshot
</Button>
) : undefined}
/>
</FleetEmptyState>
) : (
/* Snapshots table */
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel overflow-hidden">
@@ -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 (
<div className="space-y-5">
{blueprints.length === 0 ? (
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} canCreate={canEdit} />
<>
<FleetTabHeading
title="Blueprints"
subtitle="Declare compose templates once and keep matching nodes in sync."
action={canEdit ? (
<Button size="sm" className="gap-1.5" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4" strokeWidth={1.5} />
New Blueprint
</Button>
) : undefined}
/>
<FleetEmptyState>
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} canCreate={canEdit} />
</FleetEmptyState>
</>
) : (
<BlueprintCatalog
blueprints={blueprints}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Ban, Loader2, Network, Pin, Server } from 'lucide-react';
import { Ban, Loader2, Pin, Server } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { FleetTabHeading } from './FleetEmptyState';
import {
Select,
SelectContent,
@@ -93,18 +94,10 @@ export function FederationTab({ canManage }: FederationTabProps) {
return (
<div className="space-y-6">
<header className="flex items-start gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-glass-highlight border border-card-border">
<Network className="w-5 h-5 text-foreground" />
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Federation</div>
<h2 className="text-lg font-display italic">Placement control</h2>
<p className="text-sm text-muted-foreground max-w-2xl mt-0.5">
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.
</p>
</div>
</header>
<FleetTabHeading
title="Placement control"
subtitle="Mark nodes unschedulable and pin blueprints to specific nodes."
/>
<section className="rounded-xl border border-card-border bg-card">
<div className="px-4 py-3 border-b border-card-border flex items-center justify-between">
@@ -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 (
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-display italic text-[1.5rem] leading-tight text-stat-value">{title}</h2>
<p className="text-sm text-stat-subtitle">{subtitle}</p>
</div>
{action}
</div>
);
}
/**
* 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 (
<div className="flex flex-col items-center justify-center min-h-[55vh]">
{children}
</div>
);
}
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 (
<div className="mx-auto max-w-xl rounded-xl border border-card-border/60 bg-popover/30 p-8 text-center space-y-4">
<Icon className="mx-auto w-8 h-8 text-stat-subtitle" />
<div>
<h3 className="font-display italic text-[1.25rem] text-stat-value">{title}</h3>
<p className="text-sm text-stat-subtitle leading-relaxed mt-1">{description}</p>
</div>
{action}
</div>
);
}
+13 -6
View File
@@ -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 (
<div className="flex flex-col items-center justify-center py-16">
<ArrowLeftRight className="w-12 h-12 text-stat-subtitle mb-4" />
<div className="text-lg font-display italic mb-2">No nodes available</div>
<div className="text-sm text-stat-subtitle text-center max-w-md">
Add a node to the fleet to start routing traffic between containers across nodes.
</div>
<div className="space-y-4">
<FleetTabHeading
title="Routing"
subtitle="Reach containers across nodes by hostname, no VPN required."
/>
<FleetEmptyState>
<FleetEmptyCard
icon={ArrowLeftRight}
title="No nodes available"
description="Add a node to the fleet to start routing traffic between containers across nodes."
/>
</FleetEmptyState>
</div>
);
}
@@ -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<SecretSummary[]>([]);
@@ -69,18 +70,29 @@ export function SecretsTab() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-display italic text-[1.5rem] leading-tight text-stat-value">Secret bundles</h2>
<p className="text-sm text-stat-subtitle">Centralized env-var bundles, encrypted at rest, versioned, pushed to labeled nodes.</p>
</div>
<Button type="button" onClick={() => setCreating(true)} className="gap-1.5">
<Plus className="w-4 h-4" /> New bundle
</Button>
</div>
<FleetTabHeading
title="Secret bundles"
subtitle="Centralized env-var bundles, encrypted at rest, versioned, pushed to labeled nodes."
action={
<Button type="button" onClick={() => setCreating(true)} className="gap-1.5">
<Plus className="w-4 h-4" /> New bundle
</Button>
}
/>
{items.length === 0 ? (
<EmptyState onCreate={() => setCreating(true)} />
<FleetEmptyState>
<FleetEmptyCard
icon={KeyRound}
title="One source of truth for env"
description="Build a bundle of key=value pairs, push it to nodes by label, see exactly what changed before you write."
action={
<Button type="button" onClick={() => setCreating(true)} className="gap-1.5">
<Plus className="w-4 h-4" /> Create your first bundle
</Button>
}
/>
</FleetEmptyState>
) : (
<div className="rounded-xl border border-card-border/60 overflow-hidden">
<table className="w-full text-sm">
@@ -149,20 +161,3 @@ export function SecretsTab() {
</div>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="mx-auto max-w-xl rounded-xl border border-card-border/60 bg-popover/30 p-8 text-center space-y-4">
<KeyRound className="mx-auto w-8 h-8 text-stat-subtitle" />
<div>
<h3 className="font-display italic text-[1.25rem] text-stat-value">One source of truth for env</h3>
<p className="text-sm text-stat-subtitle leading-relaxed mt-1">
Build a bundle of key=value pairs, push it to nodes by label, see exactly what changed before you write.
</p>
</div>
<Button type="button" onClick={onCreate} className="gap-1.5">
<Plus className="w-4 h-4" /> Create your first bundle
</Button>
</div>
);
}