feat(admin-remnawave): provider favicon, mobile node card, meaningful icons

- Render the provider favicon in the node badge (new provider_favicon / provider_name fields), with graceful fallback.
- Mobile-friendly node card: stop the versions block from shifting right on wrapped rows, truncate long addresses, cap the provider badge.
- Give node-summary cards (total/online/offline/disabled) and traffic-period cards (2d/7d/30d/month/year) distinct, meaningful icons instead of all-the-same globe / bar-chart.
- Show traffic as 'used / limit' (e.g. 2.35 TB / ∞).
- Route every icon through the central barrel (drop the direct react-icons/pi import).
This commit is contained in:
c0mrade
2026-06-01 13:08:30 +03:00
parent 6ea767253f
commit 9309643b7e
2 changed files with 53 additions and 26 deletions

View File

@@ -134,6 +134,8 @@ export interface NodeInfo {
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
provider_uuid?: string; provider_uuid?: string;
provider_name?: string | null;
provider_favicon?: string | null;
versions?: { xray: string; node: string } | null; versions?: { xray: string; node: string } | null;
system?: { system?: {
info: { info: {

View File

@@ -23,6 +23,19 @@ import {
ServerIcon, ServerIcon,
ChartIcon, ChartIcon,
GlobeIcon, GlobeIcon,
HeartbeatIcon,
PowerIcon,
WarningCircleIcon,
CalendarIcon,
CalendarBlankIcon,
CalendarStarIcon,
ChartPieIcon,
ChartDonutIcon,
CpuIcon,
MemoryIcon,
PulseIcon,
DevicesIcon,
StatUptimeIcon,
UsersIcon, UsersIcon,
SyncIcon, SyncIcon,
RefreshIcon, RefreshIcon,
@@ -36,7 +49,6 @@ import {
BackIcon, BackIcon,
ChevronRightIcon, ChevronRightIcon,
} from '../components/icons'; } from '../components/icons';
import { PiCpu, PiMemory, PiTimer, PiDevices, PiPulse } from 'react-icons/pi';
const formatBytes = (bytes: number): string => { const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
@@ -128,6 +140,9 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) {
const limit = node.traffic_limit_bytes ?? 0; const limit = node.traffic_limit_bytes ?? 0;
const trafficPct = limit > 0 ? Math.min(100, (used / limit) * 100) : null; const trafficPct = limit > 0 ? Math.min(100, (used / limit) * 100) : null;
// Provider name: realtime metrics first, fall back to the node's own provider.
const providerLabel = providerName || node.provider_name;
return ( return (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-3.5 transition-colors hover:border-dark-600"> <div className="rounded-xl border border-dark-700 bg-dark-800/50 p-3.5 transition-colors hover:border-dark-600">
{/* Identity + actions */} {/* Identity + actions */}
@@ -145,9 +160,19 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) {
{getCountryFlag(node.country_code)} {getCountryFlag(node.country_code)}
</span> </span>
<h3 className="truncate font-semibold text-dark-100">{node.name}</h3> <h3 className="truncate font-semibold text-dark-100">{node.name}</h3>
{providerName && ( {(providerLabel || node.provider_favicon) && (
<span className="shrink-0 truncate rounded-md bg-accent-500/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent-300"> <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">
{providerName} {node.provider_favicon && (
<img
src={node.provider_favicon}
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>
)} )}
</div> </div>
@@ -186,9 +211,9 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) {
{/* Address + traffic + uptime */} {/* Address + traffic + uptime */}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-dark-400"> <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-dark-400">
<span className="flex items-center gap-1 font-mono text-dark-500"> <span className="flex min-w-0 max-w-full items-center gap-1 font-mono text-dark-500">
<GlobeIcon className="h-3 w-3" /> <GlobeIcon className="h-3 w-3 shrink-0" />
{node.address} <span className="truncate">{node.address}</span>
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="text-dark-300">{formatBytes(used)}</span> <span className="text-dark-300">{formatBytes(used)}</span>
@@ -200,11 +225,11 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) {
/> />
</span> </span>
)} )}
<span className="text-dark-500">{limit > 0 ? formatBytes(limit) : '∞'}</span> <span className="text-dark-500">/ {limit > 0 ? formatBytes(limit) : '∞'}</span>
</span> </span>
{node.xray_uptime > 0 && ( {node.xray_uptime > 0 && (
<span className="flex items-center gap-1 text-dark-500"> <span className="flex items-center gap-1 text-dark-500">
<PiTimer className="h-3 w-3" /> <StatUptimeIcon className="h-3 w-3" />
{formatUptime(node.xray_uptime)} {formatUptime(node.xray_uptime)}
</span> </span>
)} )}
@@ -246,7 +271,7 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) {
</span> </span>
</span> </span>
{(node.versions?.node || node.versions?.xray) && ( {(node.versions?.node || node.versions?.xray) && (
<span className="ml-auto flex items-center gap-2 text-dark-600"> <span className="flex w-full items-center gap-2 text-dark-600 sm:ml-auto sm:w-auto">
{node.versions?.node && <span>{node.versions.node}</span>} {node.versions?.node && <span>{node.versions.node}</span>}
{node.versions?.xray && <span>xray {node.versions.xray}</span>} {node.versions?.xray && <span>xray {node.versions.xray}</span>}
</span> </span>
@@ -527,20 +552,20 @@ function OverviewTab({
<StatCard <StatCard
label={t('admin.remnawave.overview.cpu', 'CPU Cores')} label={t('admin.remnawave.overview.cpu', 'CPU Cores')}
value={stats.server_info.cpu_cores} value={stats.server_info.cpu_cores}
icon={<PiCpu className="h-5 w-5" />} icon={<CpuIcon className="h-5 w-5" />}
color="accent" color="accent"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.memory', 'Memory')} label={t('admin.remnawave.overview.memory', 'Memory')}
value={`${memoryUsedPercent}%`} value={`${memoryUsedPercent}%`}
subValue={`${formatBytes(stats.server_info.memory_used)} / ${formatBytes(stats.server_info.memory_total)}`} subValue={`${formatBytes(stats.server_info.memory_used)} / ${formatBytes(stats.server_info.memory_total)}`}
icon={<PiMemory className="h-5 w-5" />} icon={<MemoryIcon className="h-5 w-5" />}
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'} color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.uptime', 'Uptime')} label={t('admin.remnawave.overview.uptime', 'Uptime')}
value={formatUptime(stats.server_info.uptime_seconds)} value={formatUptime(stats.server_info.uptime_seconds)}
icon={<PiTimer className="h-5 w-5" />} icon={<StatUptimeIcon className="h-5 w-5" />}
color="blue" color="blue"
/> />
</div> </div>
@@ -556,31 +581,31 @@ function OverviewTab({
<StatCard <StatCard
label={t('admin.remnawave.overview.traffic2days', '2 days')} label={t('admin.remnawave.overview.traffic2days', '2 days')}
value={formatBytes(stats.traffic_periods.last_2_days.current)} value={formatBytes(stats.traffic_periods.last_2_days.current)}
icon={<ChartIcon className="h-5 w-5" />} icon={<CalendarIcon className="h-5 w-5" />}
color="accent" color="accent"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.traffic7days', '7 days')} label={t('admin.remnawave.overview.traffic7days', '7 days')}
value={formatBytes(stats.traffic_periods.last_7_days.current)} value={formatBytes(stats.traffic_periods.last_7_days.current)}
icon={<ChartIcon className="h-5 w-5" />} icon={<ChartDonutIcon className="h-5 w-5" />}
color="blue" color="blue"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.traffic30days', '30 days')} label={t('admin.remnawave.overview.traffic30days', '30 days')}
value={formatBytes(stats.traffic_periods.last_30_days.current)} value={formatBytes(stats.traffic_periods.last_30_days.current)}
icon={<ChartIcon className="h-5 w-5" />} icon={<ChartPieIcon className="h-5 w-5" />}
color="green" color="green"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.trafficMonth', 'Month')} label={t('admin.remnawave.overview.trafficMonth', 'Month')}
value={formatBytes(stats.traffic_periods.current_month.current)} value={formatBytes(stats.traffic_periods.current_month.current)}
icon={<ChartIcon className="h-5 w-5" />} icon={<CalendarBlankIcon className="h-5 w-5" />}
color="purple" color="purple"
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.trafficYear', 'Year')} label={t('admin.remnawave.overview.trafficYear', 'Year')}
value={formatBytes(stats.traffic_periods.current_year.current)} value={formatBytes(stats.traffic_periods.current_year.current)}
icon={<ChartIcon className="h-5 w-5" />} icon={<CalendarStarIcon className="h-5 w-5" />}
color="orange" color="orange"
/> />
</div> </div>
@@ -650,7 +675,7 @@ function OverviewTab({
{devicesStats && (devicesStats.by_platform.length > 0 || devicesStats.by_app.length > 0) && ( {devicesStats && (devicesStats.by_platform.length > 0 || devicesStats.by_app.length > 0) && (
<div> <div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300"> <h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<PiDevices className="h-4 w-4" /> <DevicesIcon className="h-4 w-4" />
{t('admin.remnawave.overview.devices', 'Устройства')} ·{' '} {t('admin.remnawave.overview.devices', 'Устройства')} ·{' '}
{devicesStats.total_hwid_devices} ({devicesStats.average_devices_per_user.toFixed(1)}/ {devicesStats.total_hwid_devices} ({devicesStats.average_devices_per_user.toFixed(1)}/
{t('admin.remnawave.overview.perUser', 'юзер')}) {t('admin.remnawave.overview.perUser', 'юзер')})
@@ -717,7 +742,7 @@ function OverviewTab({
<StatCard <StatCard
label={t('admin.remnawave.overview.panelRam', 'RAM процесса')} label={t('admin.remnawave.overview.panelRam', 'RAM процесса')}
value={formatBytes(health.rss_bytes)} value={formatBytes(health.rss_bytes)}
icon={<PiMemory className="h-5 w-5" />} icon={<MemoryIcon className="h-5 w-5" />}
color="blue" color="blue"
/> />
<StatCard <StatCard
@@ -730,13 +755,13 @@ function OverviewTab({
<StatCard <StatCard
label={t('admin.remnawave.overview.eventLoopP99', 'Event-loop p99')} label={t('admin.remnawave.overview.eventLoopP99', 'Event-loop p99')}
value={`${health.event_loop_p99_ms.toFixed(1)} ms`} value={`${health.event_loop_p99_ms.toFixed(1)} ms`}
icon={<PiPulse className="h-5 w-5" />} icon={<PulseIcon className="h-5 w-5" />}
color={health.event_loop_p99_ms > 50 ? 'red' : 'green'} color={health.event_loop_p99_ms > 50 ? 'red' : 'green'}
/> />
<StatCard <StatCard
label={t('admin.remnawave.overview.panelUptime', 'Аптайм панели')} label={t('admin.remnawave.overview.panelUptime', 'Аптайм панели')}
value={formatUptime(health.uptime_seconds)} value={formatUptime(health.uptime_seconds)}
icon={<PiTimer className="h-5 w-5" />} icon={<StatUptimeIcon className="h-5 w-5" />}
color="accent" color="accent"
/> />
</div> </div>
@@ -809,25 +834,25 @@ function NodesTab({
<StatCard <StatCard
label={t('admin.remnawave.nodes.stats.total', 'Total')} label={t('admin.remnawave.nodes.stats.total', 'Total')}
value={stats.total} value={stats.total}
icon={<GlobeIcon />} icon={<ServerIcon />}
color="accent" color="accent"
/> />
<StatCard <StatCard
label={t('admin.remnawave.nodes.stats.online', 'Online')} label={t('admin.remnawave.nodes.stats.online', 'Online')}
value={stats.online} value={stats.online}
icon={<GlobeIcon />} icon={<HeartbeatIcon />}
color="green" color="green"
/> />
<StatCard <StatCard
label={t('admin.remnawave.nodes.stats.offline', 'Offline')} label={t('admin.remnawave.nodes.stats.offline', 'Offline')}
value={stats.offline} value={stats.offline}
icon={<GlobeIcon />} icon={<WarningCircleIcon />}
color="red" color="red"
/> />
<StatCard <StatCard
label={t('admin.remnawave.nodes.stats.disabled', 'Disabled')} label={t('admin.remnawave.nodes.stats.disabled', 'Disabled')}
value={stats.disabled} value={stats.disabled}
icon={<GlobeIcon />} icon={<PowerIcon />}
color="accent" color="accent"
/> />
<StatCard <StatCard