refactor(frontend): declutter ServerDetail header

Title is just the server name (placeholder names show 'Pending pairing'),
status is a small dot, and endpoint + ID (with a copy button) sit behind
an (i) InfoTip. The favorite star is subtler, and on narrow screens the
title and actions share one row.
This commit is contained in:
Juan Denis committed 2026-11-03 17:59:02 -05:00
1 parent 2bd871e820
commit e401cbaf98
9 files changed
+121 -13

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
import { cn } from '@/lib/utils';
// A Pill's dot without the label; the status text rides along as the accessible
// name and tooltip. kind: same vocabulary as Pill.
export function StatusDot({ kind = 'gray', label, className }) {
return <span role="img" aria-label={label} title={label} className={cn('sk-statusdot', `sk-statusdot--${kind}`, className)} />;
}
export default StatusDot;
+1
View File
@@ -2,6 +2,7 @@
// Shared, reusable building blocks for the new "infra console" look. Styles
// live in styles/components/_design-system.scss (sk-* namespace).
export { Pill } from './Pill';
export { StatusDot } from './StatusDot';
export {
statusKind, statusVariant, statusDotClass, statusLabel,
alertStatusKind, processStateVariant, envActionKind,
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import FavoriteStar from '@/components/FavoriteStar';
import InfoTip from '@/components/InfoTip';
import CopyButton from '@/components/CopyButton';
import { StatusDot } from '../ds';
import { serverStatusKind } from './serverDetailData';
// Header meta: [status dot] [(i) with endpoint + ID] [star]. Everything the
// header used to spell out lives behind the (i).
export default function ServerHeaderMeta({ server }) {
const { t } = useTranslation();
const status = server.status || 'pending';
const endpoint = server.hostname || server.ip_address || t('app.serverDetail.noEndpointConfigured', 'No endpoint configured');
return (
<>
<StatusDot kind={serverStatusKind(status)} label={status.charAt(0).toUpperCase() + status.slice(1)} />
<InfoTip size="md" title={t('app.serverDetail.details', 'Server details')}>
<dl className="server-detail-info">
<dt>{t('common.labels.status', 'Status')}</dt>
<dd>{status}</dd>
<dt>{t('app.serverDetail.endpoint', 'Endpoint')}</dt>
<dd>{endpoint}</dd>
<dt>{t('app.serverDetail.serverId', 'ID')}</dt>
<dd className="server-detail-info__id">
<code>{server.id}</code>
<CopyButton value={String(server.id)} label={t('app.serverDetail.copyId', 'Copy ID')} />
</dd>
</dl>
</InfoTip>
<FavoriteStar type="server" id={server.id} path={`/servers/${server.id}`} label={server.name} />
</>
);
}
@@ -5,6 +5,11 @@ import { statusKind } from '../ds/status.js';
// Server connection states ride the shared vocabulary.
export const serverStatusKind = (status) => statusKind(status);
// The backend names an unpaired server "Pending pairing (<short id>)"
// (api/servers.py _placeholder_name); the id suffix is noise in a heading.
const PLACEHOLDER_NAME = /^Pending pairing \(.*\)$/;
export const isPlaceholderServerName = (name) => PLACEHOLDER_NAME.test(name ?? '');
export const PRESET_LABELS = {
'* * * * *': 'Every minute',
'*/5 * * * *': 'Every 5 minutes',
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isPlaceholderServerName } from './serverDetailData.js';
test('backend placeholder names are detected', () => {
assert.equal(isPlaceholderServerName('Pending pairing (342ade51)'), true);
});
test('real names are left alone', () => {
assert.equal(isPlaceholderServerName('web-01'), false);
assert.equal(isPlaceholderServerName('Pending pairing'), false);
assert.equal(isPlaceholderServerName(undefined), false);
});
+5
View File
@@ -5387,7 +5387,10 @@
"alerts": "Alerts",
"areYouSureYouWantTo": "Remove this server? This can't be undone.",
"backToServers": "Back to servers",
"copyId": "Copy ID",
"cron": "Cron",
"details": "Server details",
"endpoint": "Endpoint",
"errorLoadingServer": "Couldn't load the server.",
"failedToAcknowledgeAlert": "Couldn't acknowledge the alert.",
"failedToPingServer": "Couldn't ping the server.",
@@ -5398,6 +5401,7 @@
"metrics": "Metrics",
"noEndpointConfigured": "No endpoint configured",
"packages": "Packages",
"pendingPairing": "Pending pairing",
"ping": "Ping",
"proxy": "Proxy",
"remoteAccess": "Remote access",
@@ -5405,6 +5409,7 @@
"restorePoints": "Restore points",
"sections": "Server sections",
"serverDidNotRespond": "Server did not respond",
"serverId": "ID",
"serverNotFound": "Server not found",
"serverRemovedSuccessfully": "Server removed",
"serverRespondedInMs": "Server responded in {{latency}}ms",
+4 -13
View File
@@ -1,13 +1,12 @@
import { serverStatusKind } from '../components/serverdetail/serverDetailData';
import { isPlaceholderServerName } from '../components/serverdetail/serverDetailData';
import ServerHeaderMeta from '../components/serverdetail/ServerHeaderMeta';
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import api from '../services/api';
import { useToast } from '../contexts/useToast.js';
import { useConfirm } from '../hooks/useConfirm';
import { useRecordVisit } from '@/hooks/useRecordVisit';
import FavoriteStar from '@/components/FavoriteStar';
import { Button } from '@/components/ui/button';
import { Pill } from '../components/ds';
import { Tabs, TabsContent } from '@/components/ui/tabs';
import { Tip } from '@/components/ui/tooltip';
import { Server, ArrowLeft } from 'lucide-react';
@@ -276,17 +275,9 @@ const ServerDetail = () => {
<PageLayout
className="server-detail-page"
icon={<Server size={18} />}
title={server.name}
title={isPlaceholderServerName(server.name) ? t('app.serverDetail.pendingPairing', 'Pending pairing') : 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>
</>
)}
meta={<ServerHeaderMeta server={server} />}
tabs={tabs.map((item) => ({
id: item.id,
label: item.badge ? <>{item.label}<span className="tab-badge">{item.badge}</span></> : item.label,
@@ -14,6 +14,22 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
// ------------------------------------------------------------
// Pill — status badge (dot + label)
// ------------------------------------------------------------
// StatusDot: the Pill's dot on its own, for headers that want colour without a label.
.sk-statusdot {
display: inline-block;
flex: none;
width: 8px; // geometry: status dot
height: 8px; // geometry: status dot
border-radius: 50%;
&--green { background: var(--green); }
&--amber { background: var(--amber); }
&--red { background: var(--red); }
&--cyan { background: var(--cyan); }
&--violet { background: var(--violet); }
&--gray { background: var(--text-faint); }
}
.sk-pill {
// Badge spec: 20px tall, 4px radius, 12px/500, tinted fill, flat dot.
display: inline-flex;
+34
View File
@@ -2554,3 +2554,37 @@
flex-direction: column;
gap: $space-3;
}
// Header: dot / (i) / star share one centered row beside the title; on narrow
// screens the title block and actions stay on ONE row (title truncates), tabs below.
.server-detail-page .sk-topbar {
&__titles { align-items: center; }
&__meta { display: inline-flex; align-items: center; gap: $space-2; }
.fav-star {
width: 24px; // geometry: matches the InfoTip md footprint
height: 24px; // geometry: matches the InfoTip md footprint
svg { width: 14px; height: 14px; } // geometry: inline icon
opacity: 0.6;
&:hover, &.is-on { opacity: 1; }
}
@media (max-width: 768px) {
&__titles { flex: 1 1 0; min-width: 0; flex-wrap: nowrap; }
&__title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; }
&__spacer { display: none; }
&__actions { order: 0; width: auto; margin-left: auto; justify-content: flex-end; overflow: visible; flex-wrap: nowrap; }
}
}
.server-detail-info {
display: grid;
grid-template-columns: auto 1fr;
gap: $space-1 $space-3;
margin: 0;
dt { color: var(--text-faint); }
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
&__id { display: flex; align-items: center; gap: $space-1; code { font-family: $font-mono; } }
}