refactor(frontend): detail pages and in-body tab strips use the shared top bar

This commit is contained in:
Juan Denis committed 2026-11-03 15:04:45 -05:00
1 parent 3f8b6fb878
commit e818dcaf91
12 files changed
+309 -383

No files matched your search

+24 -36
View File
@@ -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(() =>
<Button size="sm" onClick={fetchData} disabled={loading}>
<RefreshCw size={18} className={loading ? 'fleet-refresh-spinner' : ''} />
{t('common.actions.refresh', 'Refresh')}
</Button>,
[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(() => (
<>
<SegControl
aria-label={t('app.agentFleet.views', 'Fleet views')}
value={activeTab}
onChange={setActiveTab}
options={[
{ value: 'dashboard', icon: <Activity size={16} />, label: t('app.agentFleet.dashboard', 'Dashboard') },
{ value: 'versions', icon: <Package size={16} />, label: t('app.agentFleet.versions', 'Versions') },
{ value: 'rollouts', icon: <Zap size={16} />, label: t('app.agentFleet.rollouts', 'Rollouts') },
{ value: 'queue', icon: <Clock size={16} />, label: t('app.agentFleet.commandQueue', 'Command queue'), count: queuedCommands.length || undefined },
{ value: 'discovery', icon: <Search size={16} />, label: t('app.agentFleet.discovery', 'Discovery') },
{ value: 'approvals', icon: <Shield size={16} />, label: t('app.agentFleet.approvals', 'Approvals'), count: pendingServers.length || undefined },
]}
/>
<Button size="sm" onClick={fetchData} disabled={loading}>
<RefreshCw size={18} className={loading ? 'fleet-refresh-spinner' : ''} />
{t('common.actions.refresh', 'Refresh')}
</Button>
</>
), [loading, activeTab, queuedCommands.length, pendingServers.length]);
const startDiscovery = async () => {
@@ -462,30 +474,6 @@ const AgentFleet = () => {
return (
<div className="sk-tabgroup__inner">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
{[
{ 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 => (
<TabsTrigger key={tab.key} value={tab.key}>
<tab.icon size={18} />
{tab.label}
{tab.key === 'approvals' && pendingServers.length > 0 && (
<Badge variant="destructive" className="fleet-tab-count">{pendingServers.length}</Badge>
)}
{tab.key === 'queue' && queuedCommands.length > 0 && (
<Badge variant="warning" className="fleet-tab-count">{queuedCommands.length}</Badge>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{loadError && !tabHasData ? (
<ErrorState
title={t('app.agentFleet.couldntLoadFleetData', "Couldn't load fleet data.")}
+24 -17
View File
@@ -12,7 +12,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Tabs, TabsContent } from '@/components/ui/tabs';
import { SegControl } from '@/components/ds';
import { Cloud, Server } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toastError } from '@/utils/errorMessage';
@@ -26,6 +27,7 @@ const CloudProvision = () => {
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 ? (
<>
<Button size="sm" variant="outline" onClick={() => setShowCreateProvider(true)}>{t('app.cloudProvision.addProvider', 'Add provider')}</Button>
<Button size="sm" onClick={() => setShowCreateServer(true)}>{t('app.cloudProvision.newServer', 'New server')}</Button>
</>
) : null,
[user?.is_admin]
);
useTopbarActions(() => (
<>
<SegControl
aria-label={t('app.cloudProvision.views', 'Cloud views')}
value={view}
onChange={setView}
options={[
{ value: 'servers', label: t('common.labels.servers', 'Servers') },
{ value: 'providers', label: t('app.cloudProvision.providers', 'Providers') },
{ value: 'costs', label: t('app.cloudProvision.costs', 'Costs') },
]}
/>
{user?.is_admin && (
<>
<Button size="sm" variant="outline" onClick={() => setShowCreateProvider(true)}>{t('app.cloudProvision.addProvider', 'Add provider')}</Button>
<Button size="sm" onClick={() => setShowCreateServer(true)}>{t('app.cloudProvision.newServer', 'New server')}</Button>
</>
)}
</>
), [user?.is_admin, view]);
const handleCreateProvider = async () => {
try {
@@ -112,13 +125,7 @@ const CloudProvision = () => {
return (
<div className="sk-tabgroup__inner cloud-provision-page">
<Tabs defaultValue="servers">
<TabsList>
<TabsTrigger value="servers">{t('common.labels.servers', 'Servers')}</TabsTrigger>
<TabsTrigger value="providers">{t('app.cloudProvision.providers', 'Providers')}</TabsTrigger>
<TabsTrigger value="costs">{t('app.cloudProvision.costs', 'Costs')}</TabsTrigger>
</TabsList>
<Tabs value={view} onValueChange={setView}>
<TabsContent value="servers">
<div className="cloud-servers-grid">
{servers.map(srv => (
+23 -15
View File
@@ -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 && (
<SegControl
aria-label={t('app.fTPServer.views', 'FTP views')}
value={activeTab}
onChange={selectTab}
options={[
{ value: 'overview', label: t('common.labels.overview', 'Overview') },
{ value: 'users', label: t('app.fTPServer.users', 'Users') },
{ value: 'connections', label: t('app.fTPServer.connections', 'Connections') },
{ value: 'logs', label: t('common.labels.logs', 'Logs') },
]}
/>
)}
{!isInstalled ? (
<Button onClick={() => setShowInstallModal(true)}>
{t('app.fTPServer.installFtpServer', 'Install FTP server')}
@@ -541,7 +560,7 @@ function FTPServer() {
</>
)}
</>,
[isInstalled, isRunning, actionLoading],
[isInstalled, isRunning, actionLoading, activeTab],
);
if (loading) {
@@ -601,18 +620,7 @@ function FTPServer() {
/>
</KpiBand>
<Tabs value={activeTab} onValueChange={(val) => {
setActiveTab(val);
if (val === 'connections') loadConnections();
if (val === 'logs') loadLogs();
}}>
<TabsList>
<TabsTrigger value="overview">{t('common.labels.overview', 'Overview')}</TabsTrigger>
<TabsTrigger value="users">{t('app.fTPServer.users', 'Users')}</TabsTrigger>
<TabsTrigger value="connections">{t('app.fTPServer.connections', 'Connections')}</TabsTrigger>
<TabsTrigger value="logs">{t('common.labels.logs', 'Logs')}</TabsTrigger>
</TabsList>
<Tabs value={activeTab} onValueChange={selectTab}>
<TabsContent value="overview">
<div className="overview-tab">
<div className="config-section">
+40 -29
View File
@@ -15,6 +15,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 { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -337,35 +339,43 @@ const QueueDetail = () => {
}
return (
<div className="queue-page queue-detail">
<div className="queue-detail-header">
<Button variant="unstyled" type="button" className="queue-back" onClick={() => navigate('/queue')}>
<ArrowLeft size={16} /> {t('app.queueDetail.queueBus', 'Queue bus')}
</Button>
<div className="queue-detail-headline">
<div className="queue-workbar-title">
<span>{t('app.queueDetail.queue', 'Queue')}</span>
<h1>{queue?.name || queueSlug}</h1>
<em>{groupSlug} / {queueSlug}</em>
</div>
<div className="queue-detail-actions">
{viewOnly && (
<span className="queue-readonly-badge">
<Lock size={12} /> {t('app.queueDetail.readOnly', 'Read-only')}
</span>
)}
{!viewOnly && (
<Button variant="outline" onClick={() => setShowSend(true)}>
<Send size={16} /> {t('app.queueDetail.sendMessage', 'Send message')}
</Button>
)}
<Button variant="outline" onClick={() => { loadMeta(); loadMessages(statusFilter); }}>
<RefreshCw size={16} /> {t('common.actions.refresh', 'Refresh')}
<PageLayout
className="queue-page"
icon={<Inbox size={18} />}
title={queue?.name || queueSlug}
meta={(
<>
<span>{groupSlug} / {queueSlug}</span>
{viewOnly && (
<span className="queue-readonly-badge">
<Lock size={12} /> {t('app.queueDetail.readOnly', 'Read-only')}
</span>
)}
</>
)}
actions={(
<>
<Tip content={t('app.queueDetail.queueBus', 'Queue bus')}>
<Button variant="ghost" size="icon" aria-label={t('app.queueDetail.queueBus', 'Queue bus')} onClick={() => navigate('/queue')}>
<ArrowLeft size={16} />
</Button>
</div>
</div>
</div>
</Tip>
{!viewOnly && (
<Tip content={t('app.queueDetail.sendMessage', 'Send message')}>
<Button variant="ghost" size="icon" aria-label={t('app.queueDetail.sendMessage', 'Send message')} onClick={() => setShowSend(true)}>
<Send size={16} />
</Button>
</Tip>
)}
<Tip content={t('common.actions.refresh', 'Refresh')}>
<Button variant="ghost" size="icon" aria-label={t('common.actions.refresh', 'Refresh')} onClick={() => { loadMeta(); loadMessages(statusFilter); }}>
<RefreshCw size={16} />
</Button>
</Tip>
</>
)}
>
<div className="queue-detail">
<KpiBand>
<MetricCard label={t('app.queueDetail.total', 'Total')} value={stats.total || 0} />
{STATUS_ORDER.map(s => (
@@ -512,7 +522,8 @@ const QueueDetail = () => {
</Modal>
<GridFilterDrawer {...chrome.drawerProps} />
</div>
</div>
</PageLayout>
);
};
+29 -21
View File
@@ -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 (
<div className="queue-page queue-page--ops">
<PageLayout
fill
className="queue-page queue-page--ops"
icon={<Layers size={18} />}
title={t('app.queueOperations.queueBus', 'Queue bus')}
meta={`${activeGroupLabel} · ${activeStatusLabel} · ${filteredQueues.length} visible`}
actions={(
<>
<Tip content={t('app.queueOperations.group', 'Group')}>
<Button variant="ghost" size="icon" aria-label={t('app.queueOperations.group', 'Group')} onClick={() => setShowGroupModal(true)}>
<Folder size={16} />
</Button>
</Tip>
<Tip content={t('app.queueOperations.queue', 'Queue')}>
<Button variant="ghost" size="icon" aria-label={t('app.queueOperations.queue', 'Queue')} onClick={() => setShowQueueModal(true)}>
<Plus size={16} />
</Button>
</Tip>
<Tip content={t('common.actions.refresh', 'Refresh')}>
<Button variant="ghost" size="icon" aria-label={t('common.actions.refresh', 'Refresh')} onClick={() => { loadData(); loadQueues(selectedGroup); }}>
<RefreshCw size={16} />
</Button>
</Tip>
</>
)}
>
<div className="queue-ops-workspace">
<aside className="queue-fleet-rail">
<section className="queue-rail-section queue-rail-section--overview">
@@ -536,25 +563,6 @@ const QueueOperations = () => {
</aside>
<main className="queue-main">
<div className="queue-workbar">
<div className="queue-workbar-title">
<span>{t('app.queueOperations.queueBus', 'Queue bus')}</span>
<h1>{activeGroupLabel}</h1>
<em>{activeStatusLabel} · {filteredQueues.length} visible</em>
</div>
<div className="queue-workbar-actions">
<Button variant="outline" onClick={() => setShowGroupModal(true)}>
<Folder size={16} /> {t('app.queueOperations.group', 'Group')}
</Button>
<Button variant="outline" onClick={() => setShowQueueModal(true)}>
<Plus size={16} /> {t('app.queueOperations.queue', 'Queue')}
</Button>
<Button variant="outline" onClick={() => { loadData(); loadQueues(selectedGroup); }}>
<RefreshCw size={16} /> {t('common.actions.refresh', 'Refresh')}
</Button>
</div>
</div>
{/* The view name is the page's heading for the list, and
the table's own chrome sits on that same line. */}
<GridViewPicker
@@ -758,7 +766,7 @@ const QueueOperations = () => {
</Modal>
<GridFilterDrawer {...chrome.drawerProps} />
</div>
</PageLayout>
);
};
+44 -88
View File
@@ -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 (
<div className="page-container server-detail-page">
<div className="page-breadcrumb">
<Link to="/servers">{t('common.labels.servers', 'Servers')}</Link>
<span className="breadcrumb-separator">/</span>
<span>{server.name}</span>
</div>
<header className="server-detail-header">
<div className="server-detail-header__main">
<div className={`server-detail-header__avatar server-detail-header__avatar--${server.status || 'pending'}`}>
{(server.name || '?').charAt(0).toUpperCase()}
</div>
<div className="server-detail-header__identity">
<div className="server-detail-header__title-row">
<h1>{server.name}</h1>
<FavoriteStar type="server" id={server.id} path={`/servers/${server.id}`} label={server.name} />
<Pill kind={serverStatusKind(server.status)}>
{server.status || 'pending'}
</Pill>
<CopyChip
label="id"
value={server.id}
title={t('app.serverDetail.copyServerId', 'Copy server ID')}
mono
/>
</div>
<div className="server-detail-header__meta">
<span className="server-detail-header__meta-item">
{server.hostname || server.ip_address || t('app.serverDetail.noEndpointConfigured', 'No endpoint configured')}
</span>
{server.group_name && (
<>
<span className="dotsep">·</span>
<span className="server-detail-header__meta-item"><FolderTinyIcon /> {server.group_name}</span>
</>
)}
{server.os_type && (
<>
<span className="dotsep">·</span>
<span className="server-detail-header__meta-item">{server.os_type}</span>
</>
)}
{server.agent_version && (
<>
<span className="dotsep">·</span>
<span className="server-detail-header__meta-item">agent {server.agent_version}</span>
</>
)}
{server.last_seen && (
<>
<span className="dotsep">·</span>
<span className="server-detail-header__meta-item">
{t('app.serverDetail.lastSeen', 'last seen')} {formatDateTime(server.last_seen)}
</span>
</>
)}
</div>
{server.description && (
<p className="server-detail-header__description">{server.description}</p>
)}
</div>
</div>
<div className="server-detail-header__actions">
<Button variant="outline" size="sm" onClick={handlePingServer}>
<RefreshIcon /> {t('app.serverDetail.ping', 'Ping')}
</Button>
</div>
</header>
<Tabs
value={activeTab}
onValueChange={(value) =>
navigate(value === 'overview' ? `/servers/${id}` : `/servers/${id}/${value}`, { replace: true })
}
>
<TabsList>
{tabs.map((item) => (
<TabsTrigger key={item.id} value={item.id}>
{item.label}
{item.badge ? <span className="tab-badge">{item.badge}</span> : null}
</TabsTrigger>
))}
</TabsList>
<PageLayout
className="server-detail-page"
icon={<Server size={18} />}
title={server.name}
navLabel={t('app.serverDetail.sections', 'Server sections')}
meta={(
<>
<FavoriteStar type="server" id={server.id} path={`/servers/${server.id}`} label={server.name} />
<Pill kind={serverStatusKind(server.status)}>{server.status || 'pending'}</Pill>
<span>
{server.hostname || server.ip_address || t('app.serverDetail.noEndpointConfigured', 'No endpoint configured')}
</span>
</>
)}
tabs={tabs.map((item) => ({
id: item.id,
label: item.badge ? <>{item.label}<span className="tab-badge">{item.badge}</span></> : item.label,
active: item.id === activeTab,
onSelect: () => navigate(item.id === 'overview' ? `/servers/${id}` : `/servers/${id}/${item.id}`, { replace: true }),
}))}
actions={(
<>
<Tip content={t('app.serverDetail.backToServers', 'Back to servers')}>
<Button asChild variant="ghost" size="icon" aria-label={t('app.serverDetail.backToServers', 'Back to servers')}>
<Link to="/servers"><ArrowLeft size={16} /></Link>
</Button>
</Tip>
<Tip content={t('app.serverDetail.ping', 'Ping')}>
<Button variant="ghost" size="icon" aria-label={t('app.serverDetail.ping', 'Ping')} onClick={handlePingServer}>
<RefreshIcon />
</Button>
</Tip>
</>
)}
>
{server.description && (
<p className="server-detail-header__description">{server.description}</p>
)}
<Tabs value={activeTab}>
<div className="server-detail-content">
<TabsContent value="overview">
<ServerOverviewTab
@@ -434,7 +390,7 @@ const ServerDetail = () => {
onGenerated={handleTokenGenerated}
/>
)}
</div>
</PageLayout>
);
};
+20 -19
View File
@@ -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 ? (
<Button size="sm" onClick={() => setShowCreateModal(true)}>{t('app.serverTemplates.createTemplate', 'Create template')}</Button>
) : 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(() => (
<>
<SegControl
aria-label={t('app.serverTemplates.views', 'Template views')}
value={activeTab || 'library'}
onChange={setActiveTab}
options={[
{ value: 'library', label: t('app.serverTemplates.library', 'Library') },
{ value: 'templates', label: t('app.serverTemplates.templates', 'Templates'), count: templates.length || undefined },
]}
/>
{user?.is_admin && (
<Button size="sm" onClick={() => setShowCreateModal(true)}>{t('app.serverTemplates.createTemplate', 'Create template')}</Button>
)}
</>
), [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 (
<div className="sk-tabgroup__inner server-templates-page">
{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. */}
<Tabs value={activeTab || 'library'} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="library">{t('app.serverTemplates.library', 'Library')}</TabsTrigger>
<TabsTrigger value="templates">
{t('app.serverTemplates.templates', 'Templates')}{templates.length > 0 ? ` (${templates.length})` : ''}
</TabsTrigger>
</TabsList>
<TabsContent value="templates">
{loadErrors.templates && templates.length > 0 && (
<ErrorState compact error={loadErrors.templates} onRetry={loadData} />
+51 -106
View File
@@ -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 (
<div className="app-detail-page app-detail-page--wide svc-detail">
{/* 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. */}
<PageTopbar
className="svc-detail-topbar"
icon={<Layers size={18} />}
title={(
<>
<span className="svc-crumbs">
<Link to="/services">{t('common.labels.services', 'Services')}</Link>
<span className="svc-crumbs__sep">/</span>
<span className="svc-crumbs__cur">{service.name}</span>
</span>
<FavoriteStar type="service" id={service.id} path={`/services/${service.id}`} label={service.name} />
</>
)}
<PageLayout
fill
className="svc-detail"
topbarClassName="svc-detail-topbar"
icon={<Layers size={18} />}
title={service.name}
navLabel={t('app.serviceDetail.sections', 'Service sections')}
meta={(
<>
<FavoriteStar type="service" id={service.id} path={`/services/${service.id}`} label={service.name} />
{openUrl && (
<a
href={openUrl}
target="_blank"
rel="noopener noreferrer"
className="svc-detail__open"
title={t('app.serviceDetail.open', 'Open {{openUrl}}', { openUrl: openUrl })}
aria-label={t('app.serviceDetail.openInANewTab', 'Open {{name}} in a new tab', { name: service.name })}
>
<ExternalLink size={15} />
</a>
)}
<Pill kind={statusKind(service.statusInfo.dotClass)}>{service.statusInfo.label}</Pill>
<span
className="svc-detail__type-badge"
style={{ backgroundColor: service.typeInfo.bgColor, color: service.typeInfo.color, borderColor: service.typeInfo.borderColor }}
>
{service.typeInfo.label}
</span>
{service.port && <span>{t('common.labels.port', 'Port')} {service.port}</span>}
<span>{t('common.labels.created', 'Created')} {formatDate(service.created_at)}</span>
{primaryDomain && (
<a href={`https://${primaryDomain}`} target="_blank" rel="noopener noreferrer">{primaryDomain}</a>
)}
</>
)}
tabs={availableTabs.map(tab => {
const Icon = TAB_ICONS[tab];
return {
id: tab,
label: TAB_LABELS[tab] || tab,
icon: Icon ? <Icon size={15} /> : null,
active: activeTab === tab,
onSelect: () => setActiveTab(tab),
};
})}
actions={(
<>
{/* Deploy dropdown */}
@@ -407,76 +436,11 @@ const ServiceDetail = () => {
</div>
</>
)}
/>
>
{/* Everything below the full-bleed top bar lives in a centered, padded
column (.app-detail-body), matching the WordPress detail page. */}
<div className="app-detail-body">
{/* Identity — tile + name + status + type (no actions). */}
<div className="app-detail-header">
<ServiceTile name={service.name} size={52} className="svc-detail__tile" />
<div className="app-detail-title-block">
<h1>
{service.name}
{/* Before the status pill: the first thing you want
after seeing a service is live is to go look at it. */}
{openUrl && (
<a
href={openUrl}
target="_blank"
rel="noopener noreferrer"
className="svc-detail__open"
title={t('app.serviceDetail.open', 'Open {{openUrl}}', { openUrl: openUrl })}
aria-label={t('app.serviceDetail.openInANewTab', 'Open {{name}} in a new tab', { name: service.name })}
>
<ExternalLink size={15} />
</a>
)}
<Pill kind={statusKind(service.statusInfo.dotClass)}>
{service.statusInfo.label}
</Pill>
<span
className="svc-detail__type-badge"
style={{ backgroundColor: service.typeInfo.bgColor, color: service.typeInfo.color, borderColor: service.typeInfo.borderColor }}
>
{service.typeInfo.label}
</span>
</h1>
<div className="app-detail-subtitle">
{service.port && (
openUrl ? (
<a
href={openUrl}
target="_blank"
rel="noopener noreferrer"
className="svc-detail__portlink"
title={t('app.serviceDetail.open', 'Open {{openUrl}}', { openUrl: openUrl })}
>
{t('common.labels.port', 'Port')} {service.port}
<ExternalLink size={12} />
</a>
) : (
<span>{t('common.labels.port', 'Port')} {service.port}</span>
)
)}
{service.port && <span className="separator">&middot;</span>}
<span>{t('common.labels.created', 'Created')} {formatDate(service.created_at)}</span>
{primaryDomain && (
<>
<span className="separator">&middot;</span>
<a
href={`https://${primaryDomain}`}
target="_blank"
rel="noopener noreferrer"
>
{primaryDomain}
</a>
</>
)}
</div>
</div>
</div>
{/* Repo Connection Pill */}
<div className="svc-detail__repo-bar">
{isUpload ? (
@@ -573,25 +537,6 @@ const ServiceDetail = () => {
</div>
)}
{/* 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. */}
<div className="app-detail-tabs">
{availableTabs.map(tab => {
const Icon = TAB_ICONS[tab];
return (
<Button variant="unstyled" type="button"
key={tab}
className={`app-detail-tab ${activeTab === tab ? 'active' : ''} ${tab === 'settings' ? 'app-detail-tab--end' : ''}`}
onClick={() => setActiveTab(tab)}
>
{Icon && <Icon size={14} />}
{TAB_LABELS[tab] || tab}
</Button>
);
})}
</div>
{/* Tab Content */}
<div className="app-detail-content">
{activeTab === 'overview' && <OverviewTab app={service} deployConfig={deployConfig} />}
@@ -618,7 +563,7 @@ const ServiceDetail = () => {
{/* Extension slot: widgets contributed to the service detail page */}
<PluginSlot name="service.detail.tab" context={{ serviceId: service.id }} />
</div>
</div>
</PageLayout>
);
};
+17 -10
View File
@@ -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 && (
<SegControl
aria-label={t('app.statusPages.views', 'Status page views')}
value={detailTab}
onChange={setDetailTab}
options={[
{ value: 'components', label: t('app.statusPages.components', 'Components') },
{ value: 'incidents', label: t('app.statusPages.incidents', 'Incidents') },
{ value: 'settings', label: t('app.statusPages.page', 'Page') },
]}
/>
)}
<Button size="sm" variant="outline" onClick={loadPages}>
<RefreshCw size={16} />
{t('common.actions.refresh', 'Refresh')}
@@ -343,7 +356,7 @@ const StatusPages = () => {
)}
</>
),
[isAdmin]
[isAdmin, !!selectedPage, detailTab]
);
if (loading) return <PageLoader />;
@@ -423,13 +436,7 @@ const StatusPages = () => {
</div>
</div>
<Tabs defaultValue="components">
<TabsList>
<TabsTrigger value="components">{t('app.statusPages.components', 'Components')}</TabsTrigger>
<TabsTrigger value="incidents">{t('app.statusPages.incidents', 'Incidents')}</TabsTrigger>
<TabsTrigger value="settings">{t('app.statusPages.page', 'Page')}</TabsTrigger>
</TabsList>
<Tabs value={detailTab} onValueChange={setDetailTab}>
<TabsContent value="components">
<div className="status-actions-bar">
{isAdmin && (
+22 -41
View File
@@ -254,13 +254,29 @@ const WorkspaceDetail = () => {
topbarClassName="ws-detail-topbar"
contentClassName="app-detail-page app-detail-page--wide"
icon={<LayoutGrid size={18} />}
title={(
<span className="ws-crumbs">
<Link to="/workspaces">{t('app.workspaceDetail.workspaces', 'Workspaces')}</Link>
<span className="ws-crumbs__sep">/</span>
<span className="ws-crumbs__cur">{ws.name}</span>
</span>
title={ws.name}
navLabel={t('app.workspaceDetail.sections', 'Workspace sections')}
meta={(
<>
<FavoriteStar type="workspace" id={ws.id} path={`/workspaces/${ws.id}`} label={ws.name} />
{isCurrent
? <Pill kind="green">{t('app.workspaceDetail.activeWorkspace', 'active workspace')}</Pill>
: <Pill kind={ws.status === 'active' ? 'green' : 'amber'}>{ws.status}</Pill>}
<span>/{ws.slug}</span>
<span>{t('app.workspaceDetail.memberCount', { count: members.length, defaultValue_one: '1 member', defaultValue_other: '{{count}} members' })}</span>
{since && <span>since {since}</span>}
</>
)}
tabs={VALID_TABS.map((tab) => {
const Icon = TAB_META[tab].icon;
return {
id: tab,
label: TAB_META[tab].label,
icon: <Icon size={15} />,
active: activeTab === tab,
onSelect: () => setActiveTab(tab),
};
})}
>
<div className="app-detail-body">
@@ -272,43 +288,8 @@ const WorkspaceDetail = () => {
onRetry={load}
/>
)}
<div className="app-detail-header">
<ServiceTile name={ws.name} size={54} gradient={ws.primary_color || undefined} className="ws-detail__tile" />
<div className="app-detail-title-block">
<h1>
{ws.name}
<FavoriteStar type="workspace" id={ws.id} path={`/workspaces/${ws.id}`} label={ws.name} />
{isCurrent
? <Pill kind="green">{t('app.workspaceDetail.activeWorkspace', 'active workspace')}</Pill>
: <Pill kind={ws.status === 'active' ? 'green' : 'amber'}>{ws.status}</Pill>}
</h1>
<div className="app-detail-subtitle">
<span>/{ws.slug}</span>
<span className="separator">·</span>
<span>{t('app.workspaceDetail.memberCount', { count: members.length, defaultValue_one: '1 member', defaultValue_other: '{{count}} members' })}</span>
{since && <><span className="separator">·</span><span>since {since}</span></>}
</div>
</div>
</div>
{ws.description && <p className="ws-detail__desc">{ws.description}</p>}
<div className="app-detail-tabs">
{VALID_TABS.map((tab) => {
const meta = TAB_META[tab];
const Icon = meta.icon;
return (
<div
key={tab}
className={`app-detail-tab ${activeTab === tab ? 'active' : ''} ${tab === 'settings' ? 'app-detail-tab--end' : ''}`}
onClick={() => setActiveTab(tab)}
>
<Icon size={14} /> {meta.label}
</div>
);
})}
</div>
<div className="app-detail-content">
{activeTab === 'overview' && (
<WorkspaceOverviewTab
@@ -1410,6 +1410,10 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
color: var(--text-dim);
text-decoration: none;
white-space: nowrap;
// Button tabs (<button role="tab">) 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; }
}
}
}
}
@@ -447,7 +447,6 @@
display: flex;
flex-direction: column;
gap: $space-5;
padding: $space-5;
}
.queue-back {