diff --git a/frontend/src/pages/AgentFleet.jsx b/frontend/src/pages/AgentFleet.jsx index 4692219f..ac8a0869 100644 --- a/frontend/src/pages/AgentFleet.jsx +++ b/frontend/src/pages/AgentFleet.jsx @@ -22,10 +22,8 @@ import api from '../services/api'; import { useToast } from '../contexts/useToast.js'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Badge } from '@/components/ui/badge'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { MetricCard, KpiBand, Pill, Gauge, DataTable, DataTableFooter, statusKind } from '@/components/ds'; +import { MetricCard, KpiBand, Pill, Gauge, SegControl, DataTable, DataTableFooter, statusKind } from '@/components/ds'; import { useTranslation } from 'react-i18next'; import EmptyState from '../components/EmptyState'; import ErrorState from '../components/ErrorState'; @@ -105,15 +103,29 @@ const AgentFleet = () => { fetchData(); }, [fetchData]); - // Publish the Refresh button to the shared tab-group top bar; re-registers - // on `loading` so the spinner/disabled state stays in sync. - useTopbarActions(() => - , - [loading] - ); + // Publish the view switcher + Refresh to the shared tab-group top bar; + // re-registers on the values the node reads so spinner/counts stay in sync. + useTopbarActions(() => ( + <> + , label: t('app.agentFleet.dashboard', 'Dashboard') }, + { value: 'versions', icon: , label: t('app.agentFleet.versions', 'Versions') }, + { value: 'rollouts', icon: , label: t('app.agentFleet.rollouts', 'Rollouts') }, + { value: 'queue', icon: , label: t('app.agentFleet.commandQueue', 'Command queue'), count: queuedCommands.length || undefined }, + { value: 'discovery', icon: , label: t('app.agentFleet.discovery', 'Discovery') }, + { value: 'approvals', icon: , label: t('app.agentFleet.approvals', 'Approvals'), count: pendingServers.length || undefined }, + ]} + /> + + + ), [loading, activeTab, queuedCommands.length, pendingServers.length]); const startDiscovery = async () => { @@ -462,30 +474,6 @@ const AgentFleet = () => { return (
- - - {[ - { key: 'dashboard', icon: Activity, labelKey: 'app.agentFleet.dashboard', label: 'Dashboard' }, - { key: 'versions', icon: Package, labelKey: 'app.agentFleet.versions', label: 'Versions' }, - { key: 'rollouts', icon: Zap, labelKey: 'app.agentFleet.rollouts', label: 'Rollouts' }, - { key: 'queue', icon: Clock, labelKey: 'app.agentFleet.commandQueue', label: 'Command queue' }, - { key: 'discovery', icon: Search, labelKey: 'app.agentFleet.discovery', label: 'Discovery' }, - { key: 'approvals', icon: Shield, labelKey: 'app.agentFleet.approvals', label: 'Approvals' }, - ].map(tab => ( - - - {tab.label} - {tab.key === 'approvals' && pendingServers.length > 0 && ( - {pendingServers.length} - )} - {tab.key === 'queue' && queuedCommands.length > 0 && ( - {queuedCommands.length} - )} - - ))} - - - {loadError && !tabHasData ? ( { const [servers, setServers] = useState([]); const [costs, setCosts] = useState(null); const [loading, setLoading] = useState(true); + const [view, setView] = useState('servers'); const [showCreateProvider, setShowCreateProvider] = useState(false); const [showCreateServer, setShowCreateServer] = useState(false); const [providerOptions, setProviderOptions] = useState(null); @@ -54,15 +56,26 @@ const CloudProvision = () => { useEffect(() => { loadData(); }, [loadData]); // Publish the admin actions to the shared tab-group top bar. - useTopbarActions(() => - user?.is_admin ? ( - <> - - - - ) : null, - [user?.is_admin] - ); + useTopbarActions(() => ( + <> + + {user?.is_admin && ( + <> + + + + )} + + ), [user?.is_admin, view]); const handleCreateProvider = async () => { try { @@ -112,13 +125,7 @@ const CloudProvision = () => { return (
- - - {t('common.labels.servers', 'Servers')} - {t('app.cloudProvision.providers', 'Providers')} - {t('app.cloudProvision.costs', 'Costs')} - - +
{servers.map(srv => ( diff --git a/frontend/src/pages/FTPServer.jsx b/frontend/src/pages/FTPServer.jsx index 903a53c2..2d90c271 100644 --- a/frontend/src/pages/FTPServer.jsx +++ b/frontend/src/pages/FTPServer.jsx @@ -6,7 +6,7 @@ import { import useTabParam from '../hooks/useTabParam'; import { useTopbarActions } from '@/hooks/useTopbarActions'; import { api } from '../services/api'; -import { DataTable, DataTableFooter, MetricCard, KpiBand, Pill, ListToolbar } from '@/components/ds'; +import { DataTable, DataTableFooter, MetricCard, KpiBand, Pill, ListToolbar, SegControl } from '@/components/ds'; import { useTableChrome, GridViewPicker, GridChips, GridFilterButton, GridToolsMenu, GridFilterDrawer, @@ -23,7 +23,7 @@ import { Label } from '@/components/ui/label'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; import { InfoList, InfoItem } from '../components/InfoList'; import { Badge } from '@/components/ui/badge'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; import { useTranslation } from 'react-i18next'; import { errorReason, toastError } from '@/utils/errorMessage'; @@ -498,8 +498,27 @@ function FTPServer() { const isInstalled = status?.any_installed; const isRunning = status?.any_running; + const selectTab = (val) => { + setActiveTab(val); + if (val === 'connections') loadConnections(); + if (val === 'logs') loadLogs(); + }; + useTopbarActions(() => <> + {isInstalled && ( + + )} {!isInstalled ? ( -
-
- {t('app.queueDetail.queue', 'Queue')} -

{queue?.name || queueSlug}

- {groupSlug} / {queueSlug} -
-
- {viewOnly && ( - - {t('app.queueDetail.readOnly', 'Read-only')} - - )} - {!viewOnly && ( - - )} - -
-
-
- + + {!viewOnly && ( + + + + )} + + + + + )} + > +
{STATUS_ORDER.map(s => ( @@ -512,7 +522,8 @@ const QueueDetail = () => { -
+
+ ); }; diff --git a/frontend/src/pages/QueueOperations.jsx b/frontend/src/pages/QueueOperations.jsx index d5cf2468..8e0e24fb 100644 --- a/frontend/src/pages/QueueOperations.jsx +++ b/frontend/src/pages/QueueOperations.jsx @@ -18,6 +18,8 @@ import { useConfirm } from '../hooks/useConfirm'; import EmptyState from '../components/EmptyState'; import Modal from '@/components/Modal'; import { Button } from '@/components/ui/button'; +import { Tip } from '@/components/ui/tooltip'; +import PageLayout from '../layouts/PageLayout'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -454,7 +456,32 @@ const QueueOperations = () => { } return ( -
+ } + title={t('app.queueOperations.queueBus', 'Queue bus')} + meta={`${activeGroupLabel} · ${activeStatusLabel} · ${filteredQueues.length} visible`} + actions={( + <> + + + + + + + + + + + )} + >
-
-
- {t('app.queueOperations.queueBus', 'Queue bus')} -

{activeGroupLabel}

- {activeStatusLabel} · {filteredQueues.length} visible -
-
- - - -
-
- {/* The view name is the page's heading for the list, and the table's own chrome sits on that same line. */} { -
+
); }; diff --git a/frontend/src/pages/ServerDetail.jsx b/frontend/src/pages/ServerDetail.jsx index 0176f743..8d9debbc 100644 --- a/frontend/src/pages/ServerDetail.jsx +++ b/frontend/src/pages/ServerDetail.jsx @@ -8,7 +8,10 @@ import { useRecordVisit } from '@/hooks/useRecordVisit'; import FavoriteStar from '@/components/FavoriteStar'; import { Button } from '@/components/ui/button'; import { Pill } from '../components/ds'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; +import { Tip } from '@/components/ui/tooltip'; +import { Server, ArrowLeft } from 'lucide-react'; +import PageLayout from '../layouts/PageLayout'; import PackagesTab from '../components/serverdetail/PackagesTab'; import ServicesTab from '../components/serverdetail/ServicesTab'; import ServerOverviewTab from '../components/serverdetail/ServerOverviewTab'; @@ -20,8 +23,6 @@ import ServerMetricsTab from '../components/serverdetail/ServerMetricsTab'; import SurveyTab from '../components/serverdetail/SurveyTab'; import ServerSettingsTab, { TokenModal } from '../components/serverdetail/ServerSettingsTab'; import { - CopyChip, - FolderTinyIcon, RefreshIcon, } from '../components/serverdetail/serverDetailShared'; import ProxyStackPanel from '../components/proxy/ProxyStackPanel'; @@ -32,7 +33,6 @@ import { useTranslation } from 'react-i18next'; import { useAuth } from '../contexts/useAuth.js'; import ServerRestorePointsTab from '../components/serverdetail/ServerRestorePointsTab'; import { toastError } from '@/utils/errorMessage'; -import { formatDateTime } from '@/utils/intl'; // Live host metrics cadence while the server is online. const METRICS_POLL_MS = 10000; @@ -273,90 +273,46 @@ const ServerDetail = () => { ]; return ( -
-
- {t('common.labels.servers', 'Servers')} - / - {server.name} -
- -
-
-
- {(server.name || '?').charAt(0).toUpperCase()} -
-
-
-

{server.name}

- - - {server.status || 'pending'} - - -
-
- - {server.hostname || server.ip_address || t('app.serverDetail.noEndpointConfigured', 'No endpoint configured')} - - {server.group_name && ( - <> - · - {server.group_name} - - )} - {server.os_type && ( - <> - · - {server.os_type} - - )} - {server.agent_version && ( - <> - · - agent {server.agent_version} - - )} - {server.last_seen && ( - <> - · - - {t('app.serverDetail.lastSeen', 'last seen')} {formatDateTime(server.last_seen)} - - - )} -
- {server.description && ( -

{server.description}

- )} -
-
-
- -
-
- - - navigate(value === 'overview' ? `/servers/${id}` : `/servers/${id}/${value}`, { replace: true }) - } - > - - {tabs.map((item) => ( - - {item.label} - {item.badge ? {item.badge} : null} - - ))} - + } + title={server.name} + navLabel={t('app.serverDetail.sections', 'Server sections')} + meta={( + <> + + {server.status || 'pending'} + + {server.hostname || server.ip_address || t('app.serverDetail.noEndpointConfigured', 'No endpoint configured')} + + + )} + tabs={tabs.map((item) => ({ + id: item.id, + label: item.badge ? <>{item.label}{item.badge} : item.label, + active: item.id === activeTab, + onSelect: () => navigate(item.id === 'overview' ? `/servers/${id}` : `/servers/${id}/${item.id}`, { replace: true }), + }))} + actions={( + <> + + + + + + + + )} + > + {server.description && ( +

{server.description}

+ )} +
{ onGenerated={handleTokenGenerated} /> )} -
+
); }; diff --git a/frontend/src/pages/ServerTemplates.jsx b/frontend/src/pages/ServerTemplates.jsx index 54a31fa8..2ed20889 100644 --- a/frontend/src/pages/ServerTemplates.jsx +++ b/frontend/src/pages/ServerTemplates.jsx @@ -18,8 +18,8 @@ import { Badge } from '@/components/ui/badge'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue, } from '@/components/ui/select'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { DataTable, Pill, CatalogCard, CatalogGrid } from '@/components/ds'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; +import { DataTable, Pill, SegControl, CatalogCard, CatalogGrid } from '@/components/ds'; import { useTranslation } from 'react-i18next'; import { useTableChrome, GridViewPicker, GridChips, GridFilterButton, @@ -336,12 +336,24 @@ const ServerTemplates = () => { // Publish the admin "Create Template" action to the shared tab-group top bar. - useTopbarActions(() => - user?.is_admin ? ( - - ) : null, - [user?.is_admin] - ); + // Library/Templates switcher rides the bar beside it. Library leads, because + // on a fresh panel Templates is empty; the default follows the data. + useTopbarActions(() => ( + <> + + {user?.is_admin && ( + + )} + + ), [user?.is_admin, activeTab, templates.length]); // Two accessors per column where they differ: `value` is what the column // menu, the filter rules, the grouping and the export read, `sortValue` is @@ -555,18 +567,7 @@ const ServerTemplates = () => { return (
{topbarChrome} - {/* Library leads, because on a fresh panel Templates has nothing in - it — landing there showed an empty page as the first impression. - The default follows the data rather than being fixed: once you - have templates of your own, those are what you came for. */} - - {t('app.serverTemplates.library', 'Library')} - - {t('app.serverTemplates.templates', 'Templates')}{templates.length > 0 ? ` (${templates.length})` : ''} - - - {loadErrors.templates && templates.length > 0 && ( diff --git a/frontend/src/pages/ServiceDetail.jsx b/frontend/src/pages/ServiceDetail.jsx index e8157832..4036da92 100644 --- a/frontend/src/pages/ServiceDetail.jsx +++ b/frontend/src/pages/ServiceDetail.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router-dom'; import { servicePortUrl } from '@/utils/serviceUrl'; import api from '../services/api'; import { useToast } from '../contexts/useToast.js'; @@ -25,7 +25,8 @@ import EmptyState from '../components/EmptyState'; import PluginSlot from '../components/PluginSlot'; import { Layers, FileArchive, RotateCcw, LayoutDashboard, History, ScrollText, Variable, Terminal, Activity, Package, Server, SquareTerminal, Settings, Eye, ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Pill, ServiceTile, PageTopbar, statusKind } from '@/components/ds'; +import { Pill, statusKind } from '@/components/ds'; +import PageLayout from '../layouts/PageLayout'; import FavoriteStar from '@/components/FavoriteStar'; import { useRecordVisit } from '@/hooks/useRecordVisit'; import { useTranslation } from 'react-i18next'; @@ -279,24 +280,52 @@ const ServiceDetail = () => { const openUrl = primaryDomain ? null : servicePortUrl(service); return ( -
- {/* Full-bleed top bar — the canonical PageTopbar (.sk-topbar): the SAME - chrome as the Services LIST page and the WordPress detail page, so the - top menu stays consistent. Breadcrumb in the title slot, all service - actions on the right. */} - } - title={( - <> - - {t('common.labels.services', 'Services')} - / - {service.name} - - - - )} + } + title={service.name} + navLabel={t('app.serviceDetail.sections', 'Service sections')} + meta={( + <> + + {openUrl && ( + + + + )} + {service.statusInfo.label} + + {service.typeInfo.label} + + {service.port && {t('common.labels.port', 'Port')} {service.port}} + {t('common.labels.created', 'Created')} {formatDate(service.created_at)} + {primaryDomain && ( + {primaryDomain} + )} + + )} + tabs={availableTabs.map(tab => { + const Icon = TAB_ICONS[tab]; + return { + id: tab, + label: TAB_LABELS[tab] || tab, + icon: Icon ? : null, + active: activeTab === tab, + onSelect: () => setActiveTab(tab), + }; + })} actions={( <> {/* Deploy dropdown */} @@ -407,76 +436,11 @@ const ServiceDetail = () => {
)} - /> + > {/* Everything below the full-bleed top bar lives in a centered, padded column (.app-detail-body), matching the WordPress detail page. */}
- {/* Identity — tile + name + status + type (no actions). */} -
- -
-

- {service.name} - {/* Before the status pill: the first thing you want - after seeing a service is live is to go look at it. */} - {openUrl && ( - - - - )} - - {service.statusInfo.label} - - - {service.typeInfo.label} - -

-
- {service.port && ( - openUrl ? ( - - {t('common.labels.port', 'Port')} {service.port} - - - ) : ( - {t('common.labels.port', 'Port')} {service.port} - ) - )} - {service.port && ·} - {t('common.labels.created', 'Created')} {formatDate(service.created_at)} - {primaryDomain && ( - <> - · - - {primaryDomain} - - - )} -
-
-
- {/* Repo Connection Pill */}
{isUpload ? ( @@ -573,25 +537,6 @@ const ServiceDetail = () => {
)} - {/* Tab Bar — shared underline-style strip (app-detail-tabs) with a - glyph per tab; Settings is pinned to the far right (--end), exactly - like the WordPress detail page. */} -
- {availableTabs.map(tab => { - const Icon = TAB_ICONS[tab]; - return ( - - ); - })} -
- {/* Tab Content */}
{activeTab === 'overview' && } @@ -618,7 +563,7 @@ const ServiceDetail = () => { {/* Extension slot: widgets contributed to the service detail page */}
-
+ ); }; diff --git a/frontend/src/pages/StatusPages.jsx b/frontend/src/pages/StatusPages.jsx index 7f0bca85..b18c4f01 100644 --- a/frontend/src/pages/StatusPages.jsx +++ b/frontend/src/pages/StatusPages.jsx @@ -12,8 +12,8 @@ import { Textarea } from '@/components/ui/textarea'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue, } from '@/components/ui/select'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { Pill } from '@/components/ds'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; +import { Pill, SegControl } from '@/components/ds'; import { useTopbarActions } from '@/hooks/useTopbarActions'; import CopyField from '../components/CopyField'; import { useTranslation } from 'react-i18next'; @@ -112,6 +112,7 @@ const StatusPages = () => { const [selectedPage, setSelectedPage] = useState(null); const [components, setComponents] = useState([]); const [incidents, setIncidents] = useState([]); + const [detailTab, setDetailTab] = useState('components'); const [showCreatePage, setShowCreatePage] = useState(false); const [showCreateComponent, setShowCreateComponent] = useState(false); // Monitors are first-class now (core /api/v1/monitors) — a status page @@ -331,6 +332,18 @@ const StatusPages = () => { useTopbarActions(() => ( <> + {selectedPage && ( + + )}
- - - {t('app.statusPages.components', 'Components')} - {t('app.statusPages.incidents', 'Incidents')} - {t('app.statusPages.page', 'Page')} - - +
{isAdmin && ( diff --git a/frontend/src/pages/WorkspaceDetail.jsx b/frontend/src/pages/WorkspaceDetail.jsx index e03820f7..b5e5cda2 100644 --- a/frontend/src/pages/WorkspaceDetail.jsx +++ b/frontend/src/pages/WorkspaceDetail.jsx @@ -254,13 +254,29 @@ const WorkspaceDetail = () => { topbarClassName="ws-detail-topbar" contentClassName="app-detail-page app-detail-page--wide" icon={} - title={( - - {t('app.workspaceDetail.workspaces', 'Workspaces')} - / - {ws.name} - + title={ws.name} + navLabel={t('app.workspaceDetail.sections', 'Workspace sections')} + meta={( + <> + + {isCurrent + ? {t('app.workspaceDetail.activeWorkspace', 'active workspace')} + : {ws.status}} + /{ws.slug} + {t('app.workspaceDetail.memberCount', { count: members.length, defaultValue_one: '1 member', defaultValue_other: '{{count}} members' })} + {since && since {since}} + )} + tabs={VALID_TABS.map((tab) => { + const Icon = TAB_META[tab].icon; + return { + id: tab, + label: TAB_META[tab].label, + icon: , + active: activeTab === tab, + onSelect: () => setActiveTab(tab), + }; + })} >
@@ -272,43 +288,8 @@ const WorkspaceDetail = () => { onRetry={load} /> )} -
- -
-

- {ws.name} - - {isCurrent - ? {t('app.workspaceDetail.activeWorkspace', 'active workspace')} - : {ws.status}} -

-
- /{ws.slug} - · - {t('app.workspaceDetail.memberCount', { count: members.length, defaultValue_one: '1 member', defaultValue_other: '{{count}} members' })} - {since && <>·since {since}} -
-
-
- {ws.description &&

{ws.description}

} -
- {VALID_TABS.map((tab) => { - const meta = TAB_META[tab]; - const Icon = meta.icon; - return ( -
setActiveTab(tab)} - > - {meta.label} -
- ); - })} -
-
{activeTab === 'overview' && ( ) share the link tabs' flat look. + border: 0; + background: transparent; + cursor: pointer; transition: background 0.14s, color 0.14s; svg { width: 15px; height: 15px; } @@ -1480,6 +1484,17 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet'); > .sk-topbar__chrome > .sk-searchfield { flex: 1 1 calc(100% - #{2 * ($control-lg + $space-2)}); min-width: 0; } :is(button, a, [role='button']) { min-height: $control-lg; } + + // A view switcher (SegControl) is a row of its own that scrolls + // sideways rather than clipping or wrapping its segments. + > .sk-seg { + flex: 1 1 100%; + max-width: 100%; + overflow-x: auto; + scrollbar-width: none; + &::-webkit-scrollbar { display: none; } + .sk-seg__btn { flex-shrink: 0; } + } } } } diff --git a/frontend/src/styles/pages/_queue-operations.scss b/frontend/src/styles/pages/_queue-operations.scss index 66993127..a94eca7d 100644 --- a/frontend/src/styles/pages/_queue-operations.scss +++ b/frontend/src/styles/pages/_queue-operations.scss @@ -447,7 +447,6 @@ display: flex; flex-direction: column; gap: $space-5; - padding: $space-5; } .queue-back {