mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-10 05:53:05 +09:00
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:
1 parent
2bd871e820
commit
e401cbaf98
9 files changed
+121
-13
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; } }
|
||||
}
|
||||
Reference in new issue
Block a user