mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user