mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
fix(geocheck): сквозные клики через портал и safe-зоны в полноэкранном режиме
Три поломки, замеченные на dev. Клики внутри модалки срабатывали и на том, что под ней. Модалка рендерилась внутри `<div onClick>` карточки ноды: портал уносит её в document.body только по DOM, а события React прогоняет по дереву компонентов, поэтому каждый клик всплывал в обработчик аккордеона. Теперь модалка — сосед кликабельного блока, а не его потомок. Проверено A/B на настоящей модалке: в старой структуре клик по вкладке внутри модалки поднимал счётчик карточки до 1, в новой остаётся 0. По существу правка в 6 строк, остальное в дифе — переотступы от обёртки. В полноэкранном режиме отступы обнулялись целиком, и в Mini App панель инструментов уезжала под шапку Telegram, а кнопки зума — под home indicator. Теперь safe-зоны соблюдаются и в полноэкранном режиме, просто без лишнего сантиметра. Замер при отступах 96/34 на экране 844: диалог занимает 96..810, низ зум-бара на 784 — оба внутри безопасной зоны, было наоборот. Высота диалога считалась от `100dvh` мимо safe-зон, из-за чего он мог вылезти за них даже в обычном режиме. Заменено на `max-h-full`: отступы контейнера уже урезали доступное место. Заодно в полноэкранном режиме шапка ужата в одну строку с именем ноды — вертикальное место ровно то, ради чего его включают. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,11 +65,20 @@ export function GeoCheckModal({ node, onClose }: GeoCheckModalProps) {
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||||
|
// Safe-зоны обязательны и в полноэкранном режиме: в Mini App сверху
|
||||||
|
// висит шапка Telegram, снизу — home indicator, и обнулённые отступы
|
||||||
|
// прятали под ними панель инструментов и кнопки зума.
|
||||||
style={{
|
style={{
|
||||||
paddingTop: fullscreen ? 0 : 'max(1rem, env(safe-area-inset-top))',
|
paddingTop: fullscreen ? 'env(safe-area-inset-top)' : 'max(1rem, env(safe-area-inset-top))',
|
||||||
paddingBottom: fullscreen ? 0 : 'max(1rem, env(safe-area-inset-bottom))',
|
paddingBottom: fullscreen
|
||||||
paddingLeft: fullscreen ? 0 : 'max(1rem, env(safe-area-inset-left))',
|
? 'env(safe-area-inset-bottom)'
|
||||||
paddingRight: fullscreen ? 0 : 'max(1rem, env(safe-area-inset-right))',
|
: 'max(1rem, env(safe-area-inset-bottom))',
|
||||||
|
paddingLeft: fullscreen
|
||||||
|
? 'env(safe-area-inset-left)'
|
||||||
|
: 'max(1rem, env(safe-area-inset-left))',
|
||||||
|
paddingRight: fullscreen
|
||||||
|
? 'env(safe-area-inset-right)'
|
||||||
|
: 'max(1rem, env(safe-area-inset-right))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -85,23 +94,40 @@ export function GeoCheckModal({ node, onClose }: GeoCheckModalProps) {
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex w-full flex-col border border-dark-700 bg-dark-900 shadow-2xl',
|
'relative flex w-full flex-col border border-dark-700 bg-dark-900 shadow-2xl',
|
||||||
|
// max-h-full, а не 100dvh: отступы safe-зон уже урезали контейнер,
|
||||||
|
// и абсолютная высота вылезала бы за них.
|
||||||
fullscreen
|
fullscreen
|
||||||
? 'h-full max-h-none max-w-none rounded-none p-3'
|
? 'h-full max-h-full max-w-none rounded-none p-3'
|
||||||
: 'max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-2xl p-5',
|
: 'max-h-full overflow-y-auto rounded-2xl p-5',
|
||||||
// Отчёт заметно шире формы запуска — под него модалка расширяется.
|
// Отчёт заметно шире формы запуска — под него модалка расширяется.
|
||||||
!fullscreen && (job.phase === 'done' ? 'max-w-4xl' : 'max-w-2xl'),
|
!fullscreen && (job.phase === 'done' ? 'max-w-4xl' : 'max-w-2xl'),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
{/* В полноэкранном режиме шапка ужимается в одну строку: место по
|
||||||
|
вертикали — ровно то, ради чего его и включают. */}
|
||||||
|
<div
|
||||||
|
className={cn('flex items-center justify-between gap-3', fullscreen ? 'mb-2' : 'mb-4')}
|
||||||
|
>
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent-500/15 text-accent-400">
|
<span
|
||||||
<GeoCheckIcon className="h-5 w-5" />
|
className={cn(
|
||||||
|
'flex shrink-0 items-center justify-center rounded-xl bg-accent-500/15 text-accent-400',
|
||||||
|
fullscreen ? 'h-7 w-7' : 'h-9 w-9',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GeoCheckIcon className={fullscreen ? 'h-4 w-4' : 'h-5 w-5'} />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 id="geocheck-modal-title" className="text-lg font-semibold text-dark-100">
|
<h3
|
||||||
{t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
id="geocheck-modal-title"
|
||||||
|
className={cn(
|
||||||
|
'font-semibold text-dark-100',
|
||||||
|
fullscreen ? 'truncate text-sm' : 'text-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fullscreen ? node.name : t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="truncate text-xs text-dark-400">{node.name}</p>
|
{!fullscreen && <p className="truncate text-xs text-dark-400">{node.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isRunning && (
|
{!isRunning && (
|
||||||
|
|||||||
@@ -201,261 +201,266 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar
|
|||||||
? 'text-warning-400'
|
? 'text-warning-400'
|
||||||
: 'text-dark-400';
|
: 'text-dark-400';
|
||||||
|
|
||||||
|
// Модалка — сосед кликабельного блока, а не его потомок: портал уносит её
|
||||||
|
// в document.body только по DOM, а события React прогоняет по дереву
|
||||||
|
// компонентов, и клики внутри неё всплывали бы в onClick карточки.
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={`rounded-xl border border-dark-700 bg-dark-800/50 p-3.5 transition-colors hover:border-dark-600 ${
|
<div
|
||||||
hasBreakdown ? 'cursor-pointer' : ''
|
className={`rounded-xl border border-dark-700 bg-dark-800/50 p-3.5 transition-colors hover:border-dark-600 ${
|
||||||
}`}
|
hasBreakdown ? 'cursor-pointer' : ''
|
||||||
onClick={hasBreakdown ? () => setExpanded((v) => !v) : undefined}
|
}`}
|
||||||
>
|
onClick={hasBreakdown ? () => setExpanded((v) => !v) : undefined}
|
||||||
{/* Identity + actions */}
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
{/* Identity + actions */}
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
className={`h-2 w-2 shrink-0 rounded-full ${dotColor} ${isUp ? 'animate-pulse' : ''}`}
|
<span
|
||||||
title={statusText}
|
className={`h-2 w-2 shrink-0 rounded-full ${dotColor} ${isUp ? 'animate-pulse' : ''}`}
|
||||||
/>
|
title={statusText}
|
||||||
<span className="flex shrink-0 items-center gap-1 rounded-md bg-dark-700/60 px-1.5 py-0.5 text-[11px] text-dark-300">
|
/>
|
||||||
<UsersIcon className="h-3 w-3" />
|
<span className="flex shrink-0 items-center gap-1 rounded-md bg-dark-700/60 px-1.5 py-0.5 text-[11px] text-dark-300">
|
||||||
{node.users_online ?? 0}
|
<UsersIcon className="h-3 w-3" />
|
||||||
</span>
|
{node.users_online ?? 0}
|
||||||
<span className="shrink-0 text-base leading-none">
|
|
||||||
{getCountryFlag(node.country_code)}
|
|
||||||
</span>
|
|
||||||
<h3 className="truncate font-semibold text-dark-100">{node.name}</h3>
|
|
||||||
{(providerLabel || providerFavicon) && (
|
|
||||||
<span className="flex min-w-0 max-w-[7rem] shrink items-center gap-1 rounded-md bg-accent-500/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent-300">
|
|
||||||
{providerFavicon && (
|
|
||||||
<img
|
|
||||||
src={providerFavicon}
|
|
||||||
alt=""
|
|
||||||
className="h-3 w-3 shrink-0 rounded-[2px]"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{providerLabel && <span className="truncate">{providerLabel}</span>}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="shrink-0 text-base leading-none">
|
||||||
</div>
|
{getCountryFlag(node.country_code)}
|
||||||
|
</span>
|
||||||
|
<h3 className="truncate font-semibold text-dark-100">{node.name}</h3>
|
||||||
|
{(providerLabel || providerFavicon) && (
|
||||||
|
<span className="flex min-w-0 max-w-[7rem] shrink items-center gap-1 rounded-md bg-accent-500/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent-300">
|
||||||
|
{providerFavicon && (
|
||||||
|
<img
|
||||||
|
src={providerFavicon}
|
||||||
|
alt=""
|
||||||
|
className="h-3 w-3 shrink-0 rounded-[2px]"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{providerLabel && <span className="truncate">{providerLabel}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{canGeoCheck && (
|
{canGeoCheck && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setGeoCheckOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={node.is_disabled || !node.is_connected}
|
||||||
|
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
title={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
||||||
|
aria-label={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
||||||
|
>
|
||||||
|
<GeoCheckIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setGeoCheckOpen(true);
|
onAction(node.uuid, 'restart');
|
||||||
}}
|
}}
|
||||||
disabled={node.is_disabled || !node.is_connected}
|
disabled={isLoading || node.is_disabled}
|
||||||
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
title={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
title={t('admin.remnawave.nodes.restart', 'Restart')}
|
||||||
aria-label={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
|
||||||
>
|
>
|
||||||
<GeoCheckIcon className="h-3.5 w-3.5" />
|
<ArrowPathIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
<button
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
onAction(node.uuid, node.is_disabled ? 'enable' : 'disable');
|
||||||
onAction(node.uuid, 'restart');
|
}}
|
||||||
}}
|
disabled={isLoading}
|
||||||
disabled={isLoading || node.is_disabled}
|
className={`rounded-lg p-1.5 transition-colors disabled:opacity-50 ${
|
||||||
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
node.is_disabled
|
||||||
title={t('admin.remnawave.nodes.restart', 'Restart')}
|
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||||
>
|
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
|
||||||
<ArrowPathIcon className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAction(node.uuid, node.is_disabled ? 'enable' : 'disable');
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`rounded-lg p-1.5 transition-colors disabled:opacity-50 ${
|
|
||||||
node.is_disabled
|
|
||||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
|
||||||
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
node.is_disabled
|
|
||||||
? t('admin.remnawave.nodes.enable', 'Enable')
|
|
||||||
: t('admin.remnawave.nodes.disable', 'Disable')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{node.is_disabled ? (
|
|
||||||
<PlayIcon className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<StopIcon className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{hasBreakdown && (
|
|
||||||
<ChevronRightIcon
|
|
||||||
className={`h-4 w-4 text-dark-500 transition-transform ${
|
|
||||||
expanded ? 'rotate-90' : ''
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
title={
|
||||||
)}
|
node.is_disabled
|
||||||
</div>
|
? t('admin.remnawave.nodes.enable', 'Enable')
|
||||||
</div>
|
: t('admin.remnawave.nodes.disable', 'Disable')
|
||||||
|
}
|
||||||
{/* Address + traffic + uptime */}
|
>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-dark-400">
|
{node.is_disabled ? (
|
||||||
<span className="flex min-w-0 max-w-full items-center gap-1 font-mono text-dark-500">
|
<PlayIcon className="h-3.5 w-3.5" />
|
||||||
<GlobeIcon className="h-3 w-3 shrink-0" />
|
) : (
|
||||||
<span className="truncate">{node.address}</span>
|
<StopIcon className="h-3.5 w-3.5" />
|
||||||
</span>
|
)}
|
||||||
<span className="flex items-center gap-1.5">
|
</button>
|
||||||
<span className="text-dark-300">{formatBytes(used)}</span>
|
{hasBreakdown && (
|
||||||
{trafficPct !== null && (
|
<ChevronRightIcon
|
||||||
<span className="h-1 w-16 overflow-hidden rounded-full bg-dark-700">
|
className={`h-4 w-4 text-dark-500 transition-transform ${
|
||||||
<span
|
expanded ? 'rotate-90' : ''
|
||||||
className="block h-full rounded-full bg-accent-500"
|
}`}
|
||||||
style={{ width: `${trafficPct}%` }}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address + traffic + uptime */}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-dark-400">
|
||||||
|
<span className="flex min-w-0 max-w-full items-center gap-1 font-mono text-dark-500">
|
||||||
|
<GlobeIcon className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{node.address}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-dark-300">{formatBytes(used)}</span>
|
||||||
|
{trafficPct !== null && (
|
||||||
|
<span className="h-1 w-16 overflow-hidden rounded-full bg-dark-700">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-accent-500"
|
||||||
|
style={{ width: `${trafficPct}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-dark-500">/ {limit > 0 ? formatBytes(limit) : '∞'}</span>
|
||||||
|
</span>
|
||||||
|
{node.xray_uptime > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-dark-500">
|
||||||
|
<StatUptimeIcon className="h-3 w-3" />
|
||||||
|
{formatUptime(node.xray_uptime)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-dark-500">/ {limit > 0 ? formatBytes(limit) : '∞'}</span>
|
</div>
|
||||||
</span>
|
|
||||||
{node.xray_uptime > 0 && (
|
{/* Live metrics — mobile: 3 fixed rows so wrapping speeds don't reflow;
|
||||||
<span className="flex items-center gap-1 text-dark-500">
|
desktop (sm+): the original single wrap row, wide enough not to jump. */}
|
||||||
<StatUptimeIcon className="h-3 w-3" />
|
{(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && (
|
||||||
{formatUptime(node.xray_uptime)}
|
<>
|
||||||
</span>
|
{/* Mobile: processor · traffic · versions */}
|
||||||
|
<div className="mt-2 space-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:hidden">
|
||||||
|
{(loadAvg || ramPct !== null) && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{loadAvg && (
|
||||||
|
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
|
||||||
|
<CpuIcon className="h-3 w-3 shrink-0 text-dark-500" />
|
||||||
|
{loadAvg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ramPct !== null && (
|
||||||
|
<span className="flex items-center gap-1.5" title="RAM">
|
||||||
|
<MemoryIcon className="h-3 w-3 shrink-0 text-dark-500" />
|
||||||
|
<span className={ramColorClass}>{ramPct}%</span>
|
||||||
|
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-dark-400"
|
||||||
|
style={{ width: `${ramPct}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(rx > 0 || tx > 0) && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<DownloadIcon className="h-3 w-3 shrink-0 text-success-400/70" />
|
||||||
|
{formatSpeed(rx)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<UploadIcon className="h-3 w-3 shrink-0 text-accent-400/70" />
|
||||||
|
{formatSpeed(tx)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(node.versions?.node || node.versions?.xray) && (
|
||||||
|
<div className="flex items-center gap-3 text-dark-600">
|
||||||
|
{node.versions?.node && (
|
||||||
|
<span className="flex items-center gap-1" title="remnanode">
|
||||||
|
<RemnawaveIcon className="h-3 w-3 shrink-0" />
|
||||||
|
{node.versions.node}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{node.versions?.xray && (
|
||||||
|
<span className="flex items-center gap-1" title="xray core">
|
||||||
|
<XrayIcon className="h-3 w-3 shrink-0" />
|
||||||
|
{node.versions.xray}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: single wrap row (original) */}
|
||||||
|
<div className="mt-2 hidden flex-wrap items-center gap-x-3 gap-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:flex">
|
||||||
|
{ramPct !== null && (
|
||||||
|
<span className="flex items-center gap-1.5" title="RAM">
|
||||||
|
<MemoryIcon className="h-3 w-3 text-dark-500" />
|
||||||
|
<span className={ramColorClass}>{ramPct}%</span>
|
||||||
|
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-dark-400"
|
||||||
|
style={{ width: `${ramPct}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{loadAvg && (
|
||||||
|
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
|
||||||
|
<CpuIcon className="h-3 w-3 text-dark-500" />
|
||||||
|
{loadAvg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<DownloadIcon className="h-3 w-3 text-success-400/70" />
|
||||||
|
{formatSpeed(rx)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<UploadIcon className="h-3 w-3 text-accent-400/70" />
|
||||||
|
{formatSpeed(tx)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{(node.versions?.node || node.versions?.xray) && (
|
||||||
|
<span className="ml-auto flex items-center gap-2.5 text-dark-600">
|
||||||
|
{node.versions?.node && (
|
||||||
|
<span className="flex items-center gap-1" title="remnanode">
|
||||||
|
<RemnawaveIcon className="h-3 w-3" />
|
||||||
|
{node.versions.node}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{node.versions?.xray && (
|
||||||
|
<span className="flex items-center gap-1" title="xray core">
|
||||||
|
<XrayIcon className="h-3 w-3" />
|
||||||
|
{node.versions.xray}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-node traffic accordion (merged from the former Traffic tab) */}
|
||||||
|
{expanded && hasBreakdown && (
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-3 border-t border-dark-700/60 pt-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{inbounds.length > 0 && (
|
||||||
|
<NodeTrafficBreakdown
|
||||||
|
title={t('admin.remnawave.traffic.inbounds', 'Inbounds')}
|
||||||
|
items={inbounds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{outbounds.length > 0 && (
|
||||||
|
<NodeTrafficBreakdown
|
||||||
|
title={t('admin.remnawave.traffic.outbounds', 'Outbounds')}
|
||||||
|
items={outbounds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live metrics — mobile: 3 fixed rows so wrapping speeds don't reflow;
|
|
||||||
desktop (sm+): the original single wrap row, wide enough not to jump. */}
|
|
||||||
{(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && (
|
|
||||||
<>
|
|
||||||
{/* Mobile: processor · traffic · versions */}
|
|
||||||
<div className="mt-2 space-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:hidden">
|
|
||||||
{(loadAvg || ramPct !== null) && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{loadAvg && (
|
|
||||||
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
|
|
||||||
<CpuIcon className="h-3 w-3 shrink-0 text-dark-500" />
|
|
||||||
{loadAvg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{ramPct !== null && (
|
|
||||||
<span className="flex items-center gap-1.5" title="RAM">
|
|
||||||
<MemoryIcon className="h-3 w-3 shrink-0 text-dark-500" />
|
|
||||||
<span className={ramColorClass}>{ramPct}%</span>
|
|
||||||
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
|
|
||||||
<span
|
|
||||||
className="block h-full rounded-full bg-dark-400"
|
|
||||||
style={{ width: `${ramPct}%` }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(rx > 0 || tx > 0) && (
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<DownloadIcon className="h-3 w-3 shrink-0 text-success-400/70" />
|
|
||||||
{formatSpeed(rx)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<UploadIcon className="h-3 w-3 shrink-0 text-accent-400/70" />
|
|
||||||
{formatSpeed(tx)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(node.versions?.node || node.versions?.xray) && (
|
|
||||||
<div className="flex items-center gap-3 text-dark-600">
|
|
||||||
{node.versions?.node && (
|
|
||||||
<span className="flex items-center gap-1" title="remnanode">
|
|
||||||
<RemnawaveIcon className="h-3 w-3 shrink-0" />
|
|
||||||
{node.versions.node}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{node.versions?.xray && (
|
|
||||||
<span className="flex items-center gap-1" title="xray core">
|
|
||||||
<XrayIcon className="h-3 w-3 shrink-0" />
|
|
||||||
{node.versions.xray}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: single wrap row (original) */}
|
|
||||||
<div className="mt-2 hidden flex-wrap items-center gap-x-3 gap-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:flex">
|
|
||||||
{ramPct !== null && (
|
|
||||||
<span className="flex items-center gap-1.5" title="RAM">
|
|
||||||
<MemoryIcon className="h-3 w-3 text-dark-500" />
|
|
||||||
<span className={ramColorClass}>{ramPct}%</span>
|
|
||||||
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
|
|
||||||
<span
|
|
||||||
className="block h-full rounded-full bg-dark-400"
|
|
||||||
style={{ width: `${ramPct}%` }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{loadAvg && (
|
|
||||||
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
|
|
||||||
<CpuIcon className="h-3 w-3 text-dark-500" />
|
|
||||||
{loadAvg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<DownloadIcon className="h-3 w-3 text-success-400/70" />
|
|
||||||
{formatSpeed(rx)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<UploadIcon className="h-3 w-3 text-accent-400/70" />
|
|
||||||
{formatSpeed(tx)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{(node.versions?.node || node.versions?.xray) && (
|
|
||||||
<span className="ml-auto flex items-center gap-2.5 text-dark-600">
|
|
||||||
{node.versions?.node && (
|
|
||||||
<span className="flex items-center gap-1" title="remnanode">
|
|
||||||
<RemnawaveIcon className="h-3 w-3" />
|
|
||||||
{node.versions.node}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{node.versions?.xray && (
|
|
||||||
<span className="flex items-center gap-1" title="xray core">
|
|
||||||
<XrayIcon className="h-3 w-3" />
|
|
||||||
{node.versions.xray}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Per-node traffic accordion (merged from the former Traffic tab) */}
|
|
||||||
{expanded && hasBreakdown && (
|
|
||||||
<div
|
|
||||||
className="mt-3 space-y-3 border-t border-dark-700/60 pt-3"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{inbounds.length > 0 && (
|
|
||||||
<NodeTrafficBreakdown
|
|
||||||
title={t('admin.remnawave.traffic.inbounds', 'Inbounds')}
|
|
||||||
items={inbounds}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{outbounds.length > 0 && (
|
|
||||||
<NodeTrafficBreakdown
|
|
||||||
title={t('admin.remnawave.traffic.outbounds', 'Outbounds')}
|
|
||||||
items={outbounds}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{geoCheckOpen && <GeoCheckModal node={node} onClose={() => setGeoCheckOpen(false)} />}
|
{geoCheckOpen && <GeoCheckModal node={node} onClose={() => setGeoCheckOpen(false)} />}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user