3x-ui/web/html/inbounds.html
lolka1333 6ceddf83fd fix: ws hub, inbound service, and frontend correctness
- propagate DelInbound error on disable path in SetInboundEnable
- skip empty emails in updateClientTraffics to avoid constraint violations
- use consistent IN ? clause, drop redundant ErrRecordNotFound guards
- Hub.Unregister: direct removeClient fallback when channel is full
- applyClientStatsDelta: O(1) email lookup via per-inbound Map cache
- WS payload size check: Blob.size instead of .length for real byte count
2026-04-28 15:55:26 +02:00

2346 lines
No EOL
104 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak
:class="themeSwitcher.currentTheme + ' inbounds-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500"
tip='{{ i18n "loading"}}' size="large">
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched"
:style="{ marginBottom: '10px' }"
message='{{ i18n "secAlertTitle" }}' color="red"
description='{{ i18n "secAlertSsl" }}' show-icon closable>
</a-alert>
</transition>
<transition name="list" appear>
<a-row v-if="!loadingStates.fetched">
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
</a-row>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col>
<a-card size="small" :style="{ padding: '16px' }" hoverable>
<a-row>
<a-col :sm="12" :md="5">
<a-custom-statistic
title='{{ i18n "pages.inbounds.totalDownUp" }}'
:value="`${SizeFormatter.sizeFormat(total.up)} / ${SizeFormatter.sizeFormat(total.down)}`">
<template #prefix>
<a-icon type="swap"></a-icon>
</template>
</a-custom-statistic>
</a-col>
<a-col :sm="12" :md="5">
<a-custom-statistic
title='{{ i18n "pages.inbounds.totalUsage" }}'
:value="SizeFormatter.sizeFormat(total.up + total.down)"
:style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix>
<a-icon type="pie-chart"></a-icon>
</template>
</a-custom-statistic>
</a-col>
<a-col :sm="12" :md="5">
<a-custom-statistic
title='{{ i18n "pages.inbounds.allTimeTrafficUsage" }}'
:value="SizeFormatter.sizeFormat(total.allTime)"
:style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix>
<a-icon type="history"></a-icon>
</template>
</a-custom-statistic>
</a-col>
<a-col :sm="12" :md="5">
<a-custom-statistic
title='{{ i18n "pages.inbounds.inboundCount" }}'
:value="dbInbounds.length"
:style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix>
<a-icon type="bars"></a-icon>
</template>
</a-custom-statistic>
</a-col>
<a-col :sm="12" :md="4">
<a-custom-statistic title='{{ i18n "clients" }}' value=" "
:style="{ marginTop: isMobile ? '10px' : 0 }">
<template #prefix>
<a-space direction="horizontal">
<a-icon type="team"></a-icon>
<div>
<a-back-top
:target="() => document.getElementById('content-layout')"
visibility-height="200"></a-back-top>
<a-tag color="green">[[ total.clients ]]</a-tag>
<a-popover title='{{ i18n "disabled" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in total.deactive"><span>[[
clientEmail ]]</span></div>
</template>
<a-tag v-if="total.deactive.length">[[
total.deactive.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depleted" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in total.depleted"><span>[[
clientEmail ]]</span></div>
</template>
<a-tag color="red" v-if="total.depleted.length">[[
total.depleted.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depletingSoon" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in total.expiring"><span>[[
clientEmail ]]</span></div>
</template>
<a-tag color="orange"
v-if="total.expiring.length">[[
total.expiring.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "online" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in onlineClients"><span>[[
clientEmail ]]</span></div>
</template>
<a-tag color="blue" v-if="onlineClients.length">[[
onlineClients.length ]]</a-tag>
</a-popover>
</div>
</a-space>
</template>
</a-custom-statistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col>
<a-card hoverable>
<template #title>
<a-space direction="horizontal">
<a-button type="primary" icon="plus"
@click="openAddInbound">
<template v-if="!isMobile">{{ i18n
"pages.inbounds.addInbound" }}</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="primary" icon="menu">
<template v-if="!isMobile">{{ i18n
"pages.inbounds.generalActions" }}</template>
</a-button>
<a-menu slot="overlay" @click="a => generalActions(a)"
:theme="themeSwitcher.currentTheme">
<a-menu-item key="import">
<a-icon type="import"></a-icon>
{{ i18n "pages.inbounds.importInbound" }}
</a-menu-item>
<a-menu-item key="export">
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export" }}
</a-menu-item>
<a-menu-item key="subs" v-if="subSettings.enable">
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export" }} - {{ i18n
"pages.settings.subSettings" }}
</a-menu-item>
<a-menu-item key="resetInbounds">
<a-icon type="reload"></a-icon>
{{ i18n "pages.inbounds.resetAllTraffic" }}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetAllClientTraffics" }}
</a-menu-item>
<a-menu-item key="delDepletedClients"
:style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</a-space>
</template>
<template #extra>
<a-button-group>
<a-button icon="sync" @click="manualRefresh"
:loading="refreshing"></a-button>
<a-popover placement="bottomRight" trigger="click"
:overlay-class-name="themeSwitcher.currentTheme">
<template #title>
<div class="ant-custom-popover-title">
<a-switch v-model="isRefreshEnabled"
@change="toggleRefresh" size="small"></a-switch>
<span>{{ i18n "pages.inbounds.autoRefresh" }}</span>
</div>
</template>
<template #content>
<a-space direction="vertical">
<span>{{ i18n "pages.inbounds.autoRefreshInterval"
}}</span>
<a-select v-model="refreshInterval"
:disabled="!isRefreshEnabled"
:style="{ width: '100%' }"
@change="changeRefreshInterval"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in [5,10,30,60]"
:value="key*1000">[[ key ]]s</a-select-option>
</a-select>
</a-space>
</template>
<a-button icon="down"></a-button>
</a-popover>
</a-button-group>
</template>
<a-space direction="vertical">
<div
:style="isMobile ? {} : { display: 'flex', alignItems: 'center', justifyContent: 'flex-start' }">
<a-switch v-model="enableFilter"
:style="isMobile ? { marginBottom: '.5rem', display: 'flex' } : { marginRight: '.5rem' }"
@change="toggleFilter">
<a-icon slot="checkedChildren" type="search"></a-icon>
<a-icon slot="unCheckedChildren" type="filter"></a-icon>
</a-switch>
<a-input v-if="!enableFilter" v-model.lazy="searchKey"
placeholder='{{ i18n "search" }}' autofocus
:style="{ maxWidth: '300px' }"
:size="isMobile ? 'small' : ''"></a-input>
<a-radio-group v-if="enableFilter" v-model="filterBy"
@change="filterInbounds" button-style="solid"
:size="isMobile ? 'small' : ''">
<a-radio-button value>{{ i18n "none" }}</a-radio-button>
<a-radio-button value="deactive">{{ i18n "disabled"
}}</a-radio-button>
<a-radio-button value="depleted">{{ i18n "depleted"
}}</a-radio-button>
<a-radio-button value="expiring">{{ i18n "depletingSoon"
}}</a-radio-button>
<a-radio-button value="online">{{ i18n "online"
}}</a-radio-button>
</a-radio-group>
</div>
<a-table :columns="isMobile ? mobileColumns : columns"
:row-key="dbInbound => dbInbound.id"
:data-source="searchedInbounds"
:scroll="isMobile ? {} : { x: 1000 }"
:pagination=pagination(searchedInbounds)
:expand-icon-as-cell="false" :expand-row-by-click="false"
:expand-icon-column-index="0" :indent-size="0"
:row-class-name="dbInbound => (dbInbound.isMultiUser() ? '' : 'hideExpandIcon')"
:style="{ marginTop: '10px' }"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, dbInbound">
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
<a-menu slot="overlay"
@click="a => clickAction(a, dbInbound)"
:theme="themeSwitcher.currentTheme">
<a-menu-item key="edit">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item key="qrcode"
v-if="(dbInbound.isSS && !dbInbound.toInbound().isSSMultiUser) || dbInbound.isWireguard">
<a-icon type="qrcode"></a-icon>
{{ i18n "qrCode" }}
</a-menu-item>
<template v-if="dbInbound.isMultiUser()">
<a-menu-item key="addClient">
<a-icon type="user-add"></a-icon>
{{ i18n "pages.client.add"}}
</a-menu-item>
<a-menu-item key="addBulkClient">
<a-icon type="usergroup-add"></a-icon>
{{ i18n "pages.client.bulk"}}
</a-menu-item>
<a-menu-item key="copyClients">
<a-icon type="copy"></a-icon>
{{ i18n "pages.client.copyFromInbound"}}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n
"pages.inbounds.resetInboundClientTraffics"}}
</a-menu-item>
<a-menu-item key="export">
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}}
</a-menu-item>
<a-menu-item key="subs" v-if="subSettings.enable">
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}} - {{ i18n
"pages.settings.subSettings" }}
</a-menu-item>
<a-menu-item key="delDepletedClients"
:style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</template>
<template v-else>
<a-menu-item key="showInfo">
<a-icon type="info-circle"></a-icon>
{{ i18n "info"}}
</a-menu-item>
</template>
<a-menu-item key="clipboard">
<a-icon type="copy"></a-icon>
{{ i18n "pages.inbounds.exportInbound" }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<a-icon type="retweet"></a-icon> {{ i18n
"pages.inbounds.resetTraffic" }}
</a-menu-item>
<a-menu-item key="clone">
<a-icon type="block"></a-icon> {{ i18n
"pages.inbounds.clone"}}
</a-menu-item>
<a-menu-item key="delete">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon> {{ i18n "delete"}}
</span>
</a-menu-item>
<a-menu-item v-if="isMobile">
<a-switch size="small" v-model="dbInbound.enable"
@change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
{{ i18n "pages.inbounds.enable" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
<template slot="protocol" slot-scope="text, dbInbound">
<div class="protocol-tags">
<a-tag color="purple">[[ dbInbound.protocol ]]</a-tag>
<template
v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<a-tag color="green">[[ dbInbound.toInbound().stream.network ]]</a-tag>
<a-tag v-if="dbInbound.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="dbInbound.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</div>
</template>
<template slot="clients" slot-scope="text, dbInbound">
<template v-if="clientCount[dbInbound.id]">
<a-tag :style="{ margin: '0' }" color="green">[[
clientCount[dbInbound.id].clients ]]</a-tag>
<a-popover title='{{ i18n "disabled" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in clientCount[dbInbound.id].deactive"
:key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip
:overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[
clientCount[dbInbound.id].comments.get(clientEmail)
]]
</template>
<a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }"
v-if="clientCount[dbInbound.id].deactive.length">[[
clientCount[dbInbound.id].deactive.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depleted" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in clientCount[dbInbound.id].depleted"
:key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip
:overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[
clientCount[dbInbound.id].comments.get(clientEmail)
]]
</template>
<a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }"
color="red"
v-if="clientCount[dbInbound.id].depleted.length">[[
clientCount[dbInbound.id].depleted.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depletingSoon" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in clientCount[dbInbound.id].expiring"
:key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip
:overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[
clientCount[dbInbound.id].comments.get(clientEmail)
]]
</template>
<a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }"
color="orange"
v-if="clientCount[dbInbound.id].expiring.length">[[
clientCount[dbInbound.id].expiring.length ]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "online" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in clientCount[dbInbound.id].online"
:key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip
:overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[
clientCount[dbInbound.id].comments.get(clientEmail)
]]
</template>
<a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag :style="{ margin: '0', padding: '0 2px' }"
color="blue"
v-if="clientCount[dbInbound.id].online.length">[[
clientCount[dbInbound.id].online.length
]]</a-tag>
</a-popover>
</template>
</template>
<template slot="traffic" slot-scope="text, dbInbound">
<a-popover
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<table cellpadding="2" width="100%">
<tr>
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up)
]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down)
]]</td>
</tr>
<tr
v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total -
dbInbound.up - dbInbound.down) ]]</td>
</tr>
</table>
</template>
<a-tag
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
[[ SizeFormatter.sizeFormat(dbInbound.up +
dbInbound.down) ]] /
<template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template>
<template v-else>
<svg height="10px" width="14px"
viewBox="0 0 640 512" fill="currentColor">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg>
</template>
</a-tag>
</a-popover>
</template>
<template slot="allTimeInbound"
slot-scope="text, dbInbound">
<a-tag>[[ SizeFormatter.sizeFormat(dbInbound.allTime || 0)
]]</a-tag>
</template>
<template slot="enable" slot-scope="text, dbInbound">
<a-switch v-model="dbInbound.enable"
@change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
</template>
<template slot="expiryTime" slot-scope="text, dbInbound">
<a-popover v-if="dbInbound.expiryTime > 0"
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
[[ IntlUtil.formatDate(dbInbound.expiryTime) ]]
</template>
<a-tag :style="{ minWidth: '50px' }"
:color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, dbInbound._expiryTime)">
[[ IntlUtil.formatRelativeTime(dbInbound.expiryTime)
]]
</a-tag>
</a-popover>
<a-tag v-else color="purple" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512"
fill="currentColor">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg>
</a-tag>
</template>
<template slot="info" slot-scope="text, dbInbound">
<a-popover placement="bottomRight"
:overlay-class-name="themeSwitcher.currentTheme"
trigger="click">
<template slot="content">
<table cellpadding="2">
<tr>
<td>{{ i18n "pages.inbounds.protocol" }}</td>
<td>
<a-tag :style="{ margin: '0' }"
color="purple">[[ dbInbound.protocol
]]</a-tag>
<template
v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<a-tag :style="{ margin: '0' }"
color="blue">[[
dbInbound.toInbound().stream.network
]]</a-tag>
<a-tag :style="{ margin: '0' }"
v-if="dbInbound.toInbound().stream.isTls"
color="green">tls</a-tag>
<a-tag :style="{ margin: '0' }"
v-if="dbInbound.toInbound().stream.isReality"
color="green">reality</a-tag>
</template>
</td>
</tr>
<tr>
<td>{{ i18n "pages.inbounds.port" }}</td>
<td><a-tag>[[ dbInbound.port ]]</a-tag></td>
</tr>
<tr v-if="clientCount[dbInbound.id]">
<td>{{ i18n "clients" }}</td>
<td>
<a-tag :style="{ margin: '0' }" color="blue">[[
clientCount[dbInbound.id].clients
]]</a-tag>
<a-popover title='{{ i18n "disabled" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in clientCount[dbInbound.id].deactive"
:key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip
:overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[
clientCount[dbInbound.id].comments.get(clientEmail)
]]
</template>
<a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag
:style="{ margin: '0', padding: '0 2px' }"
v-if="clientCount[dbInbound.id].deactive.length">[[
clientCount[dbInbound.id].deactive.length
]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depleted" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in clientCount[dbInbound.id].depleted"
:key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip
:overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[
clientCount[dbInbound.id].comments.get(clientEmail)
]]
</template>
<a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag
:style="{ margin: '0', padding: '0 2px' }"
color="red"
v-if="clientCount[dbInbound.id].depleted.length">[[
clientCount[dbInbound.id].depleted.length
]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "depletingSoon" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in clientCount[dbInbound.id].expiring"
:key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip
:overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[
clientCount[dbInbound.id].comments.get(clientEmail)
]]
</template>
<a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag
:style="{ margin: '0', padding: '0 2px' }"
color="orange"
v-if="clientCount[dbInbound.id].expiring.length">[[
clientCount[dbInbound.id].expiring.length
]]</a-tag>
</a-popover>
<a-popover title='{{ i18n "online" }}'
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<div
v-for="clientEmail in clientCount[dbInbound.id].online"
:key="clientEmail"
class="client-popup-item">
<span>[[ clientEmail ]]</span>
<a-tooltip
:overlay-class-name="themeSwitcher.currentTheme">
<template #title>
[[
clientCount[dbInbound.id].comments.get(clientEmail)
]]
</template>
<a-icon type="message"
v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
</a-tooltip>
</div>
</template>
<a-tag
:style="{ margin: '0', padding: '0 2px' }"
color="green"
v-if="clientCount[dbInbound.id].online.length">[[
clientCount[dbInbound.id].online.length
]]</a-tag>
</a-popover>
</td>
</tr>
<tr>
<td>{{ i18n "pages.inbounds.traffic" }}</td>
<td>
<a-popover
:overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<table cellpadding="2" width="100%">
<tr>
<td>↑[[
SizeFormatter.sizeFormat(dbInbound.up)
]]</td>
<td>↓[[
SizeFormatter.sizeFormat(dbInbound.down)
]]</td>
</tr>
<tr
v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
<td>{{ i18n "remained" }}</td>
<td>[[
SizeFormatter.sizeFormat(dbInbound.total
- dbInbound.up - dbInbound.down)
]]</td>
</tr>
</table>
</template>
<a-tag
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
[[ SizeFormatter.sizeFormat(dbInbound.up +
dbInbound.down) ]] /
<template v-if="dbInbound.total > 0">
[[
SizeFormatter.sizeFormat(dbInbound.total)
]]
</template>
<template v-else>
<svg height="10px" width="14px"
viewBox="0 0 640 512"
fill="currentColor">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg>
</template>
</a-tag>
</a-popover>
</td>
</tr>
<tr>
<td>{{ i18n "pages.inbounds.expireDate" }}</td>
<td>
<a-tag
:style="{ minWidth: '50px', textAlign: 'center' }"
v-if="dbInbound.expiryTime > 0"
:color="dbInbound.isExpiry? 'red': 'blue'">
[[ IntlUtil.formatDate(dbInbound.expiryTime)
]]
</a-tag>
<a-tag v-else :style="{ textAlign: 'center' }"
color="purple" class="infinite-tag">
<svg height="10px" width="14px"
viewBox="0 0 640 512" fill="currentColor">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg>
</a-tag>
</td>
</tr>
<tr>
<td>{{ i18n
"pages.inbounds.periodicTrafficResetTitle"
}}</td>
<td>
<a-tag color="blue">[[ dbInbound.trafficReset
]]</a-tag>
</td>
</tr>
</table>
</template>
<a-badge>
<a-icon v-if="!dbInbound.enable" slot="count"
type="pause-circle"
:style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
<a-button shape="round" size="small"
:style="{ fontSize: '14px', padding: '0 10px' }">
<a-icon type="info"></a-icon>
</a-button>
</a-badge>
</a-popover>
</template>
<template slot="expandedRowRender" slot-scope="record">
<a-table :row-key="client => client.id"
:columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)"
:pagination=pagination(getInboundClients(record))
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
{{template "component/aClientTable"}}
</a-table>
</template>
</a-table>
</a-space>
</a-card>
</a-col>
</a-row>
</transition>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script
src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/uri/URI.min.js?{{ .cur_ver }}"></script>
<script
src="{{ .base_path }}assets/js/model/reality_targets.js?{{ .cur_ver }}"></script>
<script
src="{{ .base_path }}assets/js/model/inbound.js?{{ .cur_ver }}"></script>
<script
src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script>
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aCustomStatistic" .}}
{{template "component/aPersianDatepicker" .}}
{{template "modals/inboundModal"}}
{{template "modals/promptModal"}}
{{template "modals/qrcodeModal"}}
{{template "modals/textModal"}}
{{template "modals/inboundInfoModal"}}
{{template "modals/clientsModal"}}
{{template "modals/clientsBulkModal"}}
<a-modal id="copy-clients-modal"
:title="copyClientsModal.title"
:visible="copyClientsModal.visible"
:confirm-loading="copyClientsModal.confirmLoading"
ok-text='{{ i18n "pages.client.copySelected" }}'
cancel-text='{{ i18n "close" }}'
:class="themeSwitcher.currentTheme"
:closable="true"
:mask-closable="false"
@ok="() => copyClientsModal.ok()"
@cancel="() => copyClientsModal.close()"
width="900px">
<a-space direction="vertical" style="width: 100%;">
<div>
<div style="margin-bottom: 6px;">{{ i18n "pages.client.copySource" }}</div>
<a-select v-model="copyClientsModal.sourceInboundId"
style="width: 100%;"
:dropdown-class-name="themeSwitcher.currentTheme"
@change="id => copyClientsModal.onSourceChange(id)">
<a-select-option v-for="item in copyClientsModal.sources"
:key="item.id"
:value="item.id">
[[ item.label ]]
</a-select-option>
</a-select>
</div>
<div v-if="copyClientsModal.sourceInboundId">
<a-space style="margin-bottom: 10px;">
<a-button size="small" @click="() => copyClientsModal.selectAll()">{{ i18n "pages.client.selectAll" }}</a-button>
<a-button size="small" @click="() => copyClientsModal.clearAll()">{{ i18n "pages.client.clearAll" }}</a-button>
</a-space>
<a-table :columns="copyClientsColumns"
:data-source="copyClientsModal.sourceClients"
:pagination="false"
size="small"
:row-key="item => item.email"
:scroll="{ y: 280 }">
<template slot="emailCheckbox" slot-scope="text, record">
<a-checkbox :checked="copyClientsModal.selectedEmails.includes(record.email)"
@change="event => copyClientsModal.toggleEmail(record.email, event.target.checked)">
[[ record.email ]]
</a-checkbox>
</template>
</a-table>
</div>
<div v-if="copyClientsModal.showFlow">
<div style="margin-bottom: 6px;">{{ i18n "pages.client.copyFlowLabel" }}</div>
<a-select v-model="copyClientsModal.flow"
style="width: 100%;"
:dropdown-class-name="themeSwitcher.currentTheme"
allow-clear>
<a-select-option value="">{{ i18n "none" }}</a-select-option>
<a-select-option value="xtls-rprx-vision">xtls-rprx-vision</a-select-option>
<a-select-option value="xtls-rprx-vision-udp443">xtls-rprx-vision-udp443</a-select-option>
</a-select>
<div style="margin-top: 4px; font-size: 12px; opacity: 0.7;">
{{ i18n "pages.client.copyFlowHint" }}
</div>
</div>
<div v-if="copyClientsModal.selectedEmails.length > 0">
<div style="margin-bottom: 4px;">{{ i18n "pages.client.copyEmailPreview" }}</div>
<div style="max-height: 120px; overflow-y: auto;">
<a-tag v-for="preview in previewEmails" :key="preview" style="margin-bottom: 4px;">
[[ preview ]]
</a-tag>
</div>
</div>
</a-space>
</a-modal>
<script>
const copyClientsColumns = [
{ title: '{{ i18n "pages.inbounds.email" }}', width: 300, scopedSlots: { customRender: 'emailCheckbox' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 160, dataIndex: 'trafficLabel' },
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, dataIndex: 'expiryLabel' },
];
const copyClientsModal = {
visible: false,
confirmLoading: false,
title: '',
targetInboundId: 0,
targetInboundRemark: '',
targetProtocol: '',
showFlow: false,
flow: '',
sourceInboundId: undefined,
sources: [],
sourceClients: [],
selectedEmails: [],
show(targetDbInbound) {
if (!targetDbInbound) return;
const sources = app.dbInbounds
.filter(row => row.id !== targetDbInbound.id && typeof row.isMultiUser === 'function' && row.isMultiUser())
.map(row => {
const clients = app.getInboundClients(row) || [];
return { id: row.id, label: `${row.remark} (${row.protocol}, ${clients.length})` };
});
let showFlow = false;
try {
const targetInbound = targetDbInbound.toInbound();
showFlow = !!(targetInbound && typeof targetInbound.canEnableTlsFlow === 'function' && targetInbound.canEnableTlsFlow());
} catch (e) {
showFlow = false;
}
copyClientsModal.targetInboundId = targetDbInbound.id;
copyClientsModal.targetInboundRemark = targetDbInbound.remark;
copyClientsModal.targetProtocol = targetDbInbound.protocol;
copyClientsModal.showFlow = showFlow;
copyClientsModal.flow = '';
copyClientsModal.title = `{{ i18n "pages.client.copyToInbound" }} ${targetDbInbound.remark}`;
copyClientsModal.sources = sources;
copyClientsModal.sourceInboundId = undefined;
copyClientsModal.sourceClients = [];
copyClientsModal.selectedEmails = [];
copyClientsModal.confirmLoading = false;
copyClientsModal.visible = true;
},
close() {
copyClientsModal.visible = false;
copyClientsModal.confirmLoading = false;
},
onSourceChange(sourceInboundId) {
copyClientsModal.selectedEmails = [];
const sourceInbound = app.dbInbounds.find(row => row.id === Number(sourceInboundId));
if (!sourceInbound) {
copyClientsModal.sourceClients = [];
return;
}
const sourceClients = app.getInboundClients(sourceInbound) || [];
copyClientsModal.sourceClients = sourceClients.map(client => {
const stats = app.getClientStats(sourceInbound, client.email);
const used = stats ? ((stats.up || 0) + (stats.down || 0)) : 0;
let expiryLabel = '{{ i18n "unlimited" }}';
if (client.expiryTime > 0) {
expiryLabel = IntlUtil.formatDate(client.expiryTime);
} else if (client.expiryTime < 0) {
expiryLabel = `${-client.expiryTime / 86400000}d`;
}
return {
email: client.email,
trafficLabel: SizeFormatter.sizeFormat(used),
expiryLabel,
};
});
},
toggleEmail(email, checked) {
const selected = copyClientsModal.selectedEmails.slice();
if (checked) {
if (!selected.includes(email)) selected.push(email);
} else {
const idx = selected.indexOf(email);
if (idx >= 0) selected.splice(idx, 1);
}
copyClientsModal.selectedEmails = selected;
},
selectAll() {
copyClientsModal.selectedEmails = copyClientsModal.sourceClients.map(item => item.email);
},
clearAll() {
copyClientsModal.selectedEmails = [];
},
async ok() {
if (!copyClientsModal.sourceInboundId) {
app.$message.error('{{ i18n "pages.client.copySelectSourceFirst" }}');
return;
}
copyClientsModal.confirmLoading = true;
const payload = {
sourceInboundId: copyClientsModal.sourceInboundId,
clientEmails: copyClientsModal.selectedEmails,
};
if (copyClientsModal.showFlow && copyClientsModal.flow) {
payload.flow = copyClientsModal.flow;
}
try {
const msg = await HttpUtil.post(`/panel/api/inbounds/${copyClientsModal.targetInboundId}/copyClients`, payload);
if (!msg || !msg.success) return;
const obj = msg.obj || {};
const addedCount = (obj.added || []).length;
const errorList = obj.errors || [];
if (addedCount > 0) {
app.$message.success(`{{ i18n "pages.client.copyResultSuccess" }}: ${addedCount}`);
} else {
app.$message.warning('{{ i18n "pages.client.copyResultNone" }}');
}
if (errorList.length > 0) {
app.$message.error(`{{ i18n "pages.client.copyResultErrors" }}: ${errorList.join('; ')}`);
}
copyClientsModal.close();
await app.getDBInbounds();
} finally {
copyClientsModal.confirmLoading = false;
}
},
};
const copyClientsModalApp = new Vue({
delimiters: ['[[', ']]'],
el: '#copy-clients-modal',
data: {
copyClientsModal,
copyClientsColumns,
themeSwitcher,
},
computed: {
previewEmails() {
if (!this.copyClientsModal.targetInboundId) return [];
return this.copyClientsModal.selectedEmails.map(email => `${email}_${this.copyClientsModal.targetInboundId}`);
},
},
});
</script>
<script>
const columns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 30,
responsive: ["xs"],
}, {
title: '{{ i18n "pages.inbounds.operate" }}',
align: 'center',
width: 30,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.inbounds.enable" }}',
align: 'center',
width: 35,
scopedSlots: { customRender: 'enable' },
}, {
title: '{{ i18n "pages.inbounds.remark" }}',
align: 'center',
width: 60,
dataIndex: "remark",
}, {
title: '{{ i18n "pages.inbounds.port" }}',
align: 'center',
dataIndex: "port",
width: 40,
}, {
title: '{{ i18n "pages.inbounds.protocol" }}',
align: 'left',
width: 70,
scopedSlots: { customRender: 'protocol' },
}, {
title: '{{ i18n "clients" }}',
align: 'left',
width: 50,
scopedSlots: { customRender: 'clients' },
}, {
title: '{{ i18n "pages.inbounds.traffic" }}',
align: 'center',
width: 90,
scopedSlots: { customRender: 'traffic' },
}, {
title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'allTimeInbound' },
}, {
title: '{{ i18n "pages.inbounds.expireDate" }}',
align: 'center',
width: 40,
scopedSlots: { customRender: 'expiryTime' },
}];
const mobileColumns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 10,
responsive: ["s"],
}, {
title: '{{ i18n "pages.inbounds.operate" }}',
align: 'center',
width: 25,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.inbounds.remark" }}',
align: 'left',
width: 70,
dataIndex: "remark",
}, {
title: '{{ i18n "pages.inbounds.info" }}',
align: 'center',
width: 10,
scopedSlots: { customRender: 'info' },
}];
const innerColumns = [
{ title: '{{ i18n "pages.inbounds.operate" }}', width: 70, scopedSlots: { customRender: 'actions' } },
{ title: '{{ i18n "pages.inbounds.enable" }}', width: 30, scopedSlots: { customRender: 'enable' } },
{ title: '{{ i18n "online" }}', width: 32, scopedSlots: { customRender: 'online' } },
{ title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 60, align: 'center', scopedSlots: { customRender: 'allTime' } },
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
];
const innerMobileColumns = [
{ title: '{{ i18n "pages.inbounds.operate" }}', width: 10, align: 'center', scopedSlots: { customRender: 'actionMenu' } },
{ title: '{{ i18n "pages.inbounds.client" }}', width: 90, align: 'left', scopedSlots: { customRender: 'client' } },
{ title: '{{ i18n "pages.inbounds.info" }}', width: 10, align: 'center', scopedSlots: { customRender: 'info' } },
];
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
mixins: [MediaQueryMixin],
data: {
themeSwitcher,
persianDatepicker,
loadingStates: {
fetched: false,
spinning: false
},
inbounds: [],
dbInbounds: [],
searchKey: '',
enableFilter: false,
filterBy: '',
searchedInbounds: [],
expireDiff: 0,
trafficDiff: 0,
defaultCert: '',
defaultKey: '',
clientCount: {},
onlineClients: [],
lastOnlineMap: {},
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
refreshing: false,
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
subSettings: {
enable: false,
subTitle: '',
subURI: '',
subJsonURI: '',
subJsonEnable: false,
},
remarkModel: '-ieo',
datepicker: 'gregorian',
tgBotEnable: false,
showAlert: false,
ipLimitEnable: false,
pageSize: 0,
},
methods: {
loading(spinning = true) {
this.loadingStates.spinning = spinning;
},
// applyClientStatsDelta updates client traffic counters and inbound totals
// in-place from a WebSocket delta payload. Avoids full-list re-fetch and
// re-render — critical at 10k+ client scale.
applyClientStatsDelta(payload) {
if (!payload || typeof payload !== 'object') return;
const inboundsById = new Map();
this.dbInbounds.forEach(ib => inboundsById.set(ib.id, ib));
const touched = new Set();
// Per-inbound email→clientStat lookup cache. Without this, finding
// each delta target was O(N) (linear scan of clientStats), which
// turned into O(activeClients × totalClients) over the loop and
// re-introduced UI freezes at 10k+ client scale. We invalidate the
// cache when the underlying clientStats array reference changes.
const statsByEmail = (dbInbound) => {
if (!Array.isArray(dbInbound.clientStats)) return null;
if (dbInbound._clientStatsMap && dbInbound._clientStatsMapSrc === dbInbound.clientStats) {
return dbInbound._clientStatsMap;
}
const map = new Map();
for (const cs of dbInbound.clientStats) map.set(cs.email, cs);
dbInbound._clientStatsMap = map;
dbInbound._clientStatsMapSrc = dbInbound.clientStats;
return map;
};
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
for (const stat of payload.clients) {
const dbInbound = inboundsById.get(stat.inboundId);
if (!dbInbound || !Array.isArray(dbInbound.clientStats)) continue;
const csMap = statsByEmail(dbInbound);
const cs = csMap ? csMap.get(stat.email) : null;
if (!cs) continue;
cs.up = stat.up;
cs.down = stat.down;
// allTime is the cumulative-historical counter shown in the
// "Общий трафик" column. The previous handler updated up/down/
// total but skipped allTime, so that column stayed frozen at
// its initial-page-load value until a manual refresh.
if (stat.allTime !== undefined) cs.allTime = stat.allTime;
if (stat.total !== undefined) cs.total = stat.total;
if (stat.expiryTime !== undefined) cs.expiryTime = stat.expiryTime;
if (stat.lastOnline !== undefined) cs.lastOnline = stat.lastOnline;
if (stat.enable !== undefined) cs.enable = stat.enable;
touched.add(stat.inboundId);
}
}
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
for (const summary of payload.inbounds) {
const dbInbound = inboundsById.get(summary.id);
if (!dbInbound) continue;
dbInbound.up = summary.up;
dbInbound.down = summary.down;
if (summary.total !== undefined) dbInbound.total = summary.total;
if (summary.allTime !== undefined) dbInbound.allTime = summary.allTime;
if (summary.enable !== undefined) dbInbound.enable = summary.enable;
}
}
// Recompute clientCount for inbounds whose stats changed. The cached
// parsed Inbound is fetched via dbInbound.toInbound() — earlier
// versions used `this.inbounds.find(ib => ib.id === id)` which
// ALWAYS returned undefined (the Inbound class has no id field), so
// this branch silently never ran and depleted/expiring/online filters
// never refreshed from delta updates.
if (touched.size > 0) {
for (const id of touched) {
const dbInbound = inboundsById.get(id);
if (dbInbound) {
this.$set(this.clientCount, id, this.getClientCounts(dbInbound, dbInbound.toInbound()));
}
}
}
// Re-run filter/search so the displayed slice picks up updated values.
if (this.enableFilter) {
this.filterInbounds();
} else {
this.searchInbounds(this.searchKey);
}
},
async getDBInbounds() {
this.refreshing = true;
const msg = await HttpUtil.get('/panel/api/inbounds/list');
if (!msg.success) {
this.refreshing = false;
return;
}
await this.getLastOnlineMap();
await this.getOnlineUsers();
this.setInbounds(msg.obj);
setTimeout(() => {
this.refreshing = false;
}, 500);
},
async getOnlineUsers() {
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
if (!msg.success) {
return;
}
this.onlineClients = msg.obj != null ? msg.obj : [];
},
async getLastOnlineMap() {
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
if (!msg.success || !msg.obj) return;
this.lastOnlineMap = msg.obj || {}
},
async getDefaultSettings() {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg.success) {
return;
}
const settings = msg.obj || {};
this.expireDiff = settings.expireDiff * 86400000;
this.trafficDiff = settings.trafficDiff * 1073741824;
this.defaultCert = settings.defaultCert;
this.defaultKey = settings.defaultKey;
this.tgBotEnable = settings.tgBotEnable;
this.subSettings = {
enable: settings.subEnable,
subTitle: settings.subTitle,
subURI: settings.subURI,
subJsonURI: settings.subJsonURI,
subJsonEnable: settings.subJsonEnable,
};
this.pageSize = settings.pageSize;
this.remarkModel = settings.remarkModel;
this.datepicker = settings.datepicker;
this.ipLimitEnable = settings.ipLimitEnable;
},
setInbounds(dbInbounds) {
this.inbounds.splice(0);
this.dbInbounds.splice(0);
// Drop every existing key — Vue.delete keeps it reactive so any
// template expression watching clientCount[id] re-renders cleanly.
for (const key of Object.keys(this.clientCount)) {
this.$delete(this.clientCount, key);
}
for (const inbound of dbInbounds) {
const dbInbound = new DBInbound(inbound);
to_inbound = dbInbound.toInbound()
this.inbounds.push(to_inbound);
this.dbInbounds.push(dbInbound);
if ([Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS].includes(inbound.protocol)) {
if (dbInbound.isSS && (!to_inbound.isSSMultiUser)) {
continue;
}
// Reactive add — direct assignment on the map would not trigger
// template updates in Vue 2.
this.$set(this.clientCount, inbound.id, this.getClientCounts(inbound, to_inbound));
}
}
if (!this.loadingStates.fetched) {
this.loadingStates.fetched = true
}
if (this.enableFilter) {
this.filterInbounds();
} else {
this.searchInbounds(this.searchKey);
}
},
getClientCounts(dbInbound, inbound) {
let clientCount = 0, active = [], deactive = [], depleted = [], expiring = [], online = [], comments = new Map();
clients = inbound.clients;
clientStats = dbInbound.clientStats
now = new Date().getTime()
if (clients) {
clientCount = clients.length;
if (dbInbound.enable) {
clients.forEach(client => {
if (client.comment) {
comments.set(client.email, client.comment)
}
if (client.enable) {
active.push(client.email);
if (this.isClientOnline(client.email)) online.push(client.email);
} else {
deactive.push(client.email);
}
});
clientStats.forEach(stats => {
const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
if (expired || exhausted) {
depleted.push(stats.email);
} else {
const expiringSoon = (stats.expiryTime > 0 && (stats.expiryTime - now < this.expireDiff)) ||
(stats.total > 0 && (stats.total - (stats.up + stats.down) < this.trafficDiff));
if (expiringSoon) expiring.push(stats.email);
}
});
} else {
clients.forEach(client => {
deactive.push(client.email);
});
}
}
return {
clients: clientCount,
active: active,
deactive: deactive,
depleted: depleted,
expiring: expiring,
online: online,
comments: comments,
};
},
searchInbounds(key) {
if (ObjectUtil.isEmpty(key)) {
this.searchedInbounds = this.dbInbounds.slice();
} else {
this.searchedInbounds.splice(0, this.searchedInbounds.length);
this.dbInbounds.forEach(inbound => {
if (ObjectUtil.deepSearch(inbound, key)) {
const newInbound = new DBInbound(inbound);
const inboundSettings = JSON.parse(inbound.settings);
if (inboundSettings.hasOwnProperty('clients')) {
const searchedSettings = { "clients": [] };
inboundSettings.clients.forEach(client => {
if (ObjectUtil.deepSearch(client, key)) {
searchedSettings.clients.push(client);
}
});
newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, searchedSettings);
}
this.searchedInbounds.push(newInbound);
}
});
}
},
filterInbounds() {
if (ObjectUtil.isEmpty(this.filterBy)) {
this.searchedInbounds = this.dbInbounds.slice();
} else {
this.searchedInbounds.splice(0, this.searchedInbounds.length);
this.dbInbounds.forEach(inbound => {
const newInbound = new DBInbound(inbound);
const inboundSettings = JSON.parse(inbound.settings);
if (this.clientCount[inbound.id] && this.clientCount[inbound.id].hasOwnProperty(this.filterBy)) {
const list = this.clientCount[inbound.id][this.filterBy];
if (list.length > 0) {
const filteredSettings = { "clients": [] };
if (inboundSettings.clients) {
inboundSettings.clients.forEach(client => {
if (list.includes(client.email)) {
filteredSettings.clients.push(client);
}
});
}
newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, filteredSettings);
this.searchedInbounds.push(newInbound);
}
}
});
}
},
toggleFilter() {
if (this.enableFilter) {
this.searchKey = '';
} else {
this.filterBy = '';
this.searchedInbounds = this.dbInbounds.slice();
}
},
generalActions(action) {
switch (action.key) {
case "import":
this.importInbound();
break;
case "export":
this.exportAllLinks();
break;
case "subs":
this.exportAllSubs();
break;
case "resetInbounds":
this.resetAllTraffic();
break;
case "resetClients":
this.resetAllClientTraffics(-1);
break;
case "delDepletedClients":
this.delDepletedClients(-1)
break;
}
},
clickAction(action, dbInbound) {
switch (action.key) {
case "qrcode":
this.showQrcode(dbInbound.id);
break;
case "showInfo":
this.showInfo(dbInbound.id);
break;
case "edit":
this.openEditInbound(dbInbound.id);
break;
case "addClient":
this.openAddClient(dbInbound.id)
break;
case "addBulkClient":
this.openAddBulkClient(dbInbound.id)
break;
case "copyClients":
copyClientsModal.show(dbInbound);
break;
case "export":
this.inboundLinks(dbInbound.id);
break;
case "subs":
this.exportSubs(dbInbound.id);
break;
case "clipboard":
this.copy(dbInbound.id);
break;
case "resetTraffic":
this.resetTraffic(dbInbound.id);
break;
case "resetClients":
this.resetAllClientTraffics(dbInbound.id);
break;
case "clone":
this.openCloneInbound(dbInbound);
break;
case "delete":
this.delInbound(dbInbound.id);
break;
case "delDepletedClients":
this.delDepletedClients(dbInbound.id)
break;
}
},
openCloneInbound(dbInbound) {
this.$confirm({
title: '{{ i18n "pages.inbounds.cloneInbound"}} \"' + dbInbound.remark + '\"',
content: '{{ i18n "pages.inbounds.cloneInboundContent"}}',
okText: '{{ i18n "pages.inbounds.cloneInboundOk"}}',
class: themeSwitcher.currentTheme,
cancelText: '{{ i18n "cancel" }}',
onOk: () => {
const baseInbound = dbInbound.toInbound();
dbInbound.up = 0;
dbInbound.down = 0;
this.cloneInbound(baseInbound, dbInbound);
},
});
},
async cloneInbound(baseInbound, dbInbound) {
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark + " - Cloned",
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: '',
port: RandomUtil.randomInteger(10000, 60000),
protocol: baseInbound.protocol,
settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
streamSettings: baseInbound.stream.toString(),
sniffing: baseInbound.sniffing.toString(),
};
await this.submit('/panel/api/inbounds/add', data, inModal);
},
openAddInbound() {
inModal.show({
title: '{{ i18n "pages.inbounds.addInbound"}}',
okText: '{{ i18n "create"}}',
cancelText: '{{ i18n "close" }}',
confirm: async (inbound, dbInbound) => {
await this.addInbound(inbound, dbInbound, inModal);
},
isEdit: false
});
},
openEditInbound(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
const inbound = dbInbound.toInbound();
inModal.show({
title: '{{ i18n "pages.inbounds.modifyInbound"}}',
okText: '{{ i18n "update"}}',
cancelText: '{{ i18n "close" }}',
inbound: inbound,
dbInbound: dbInbound,
confirm: async (inbound, dbInbound) => {
await this.updateInbound(inbound, dbInbound);
},
isEdit: true
});
},
async addInbound(inbound, dbInbound) {
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: inbound.listen,
port: inbound.port,
protocol: inbound.protocol,
settings: inbound.settings.toString(),
};
if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
}
data.sniffing = inbound.sniffing.toString();
await this.submit('/panel/api/inbounds/add', data, inModal);
},
async updateInbound(inbound, dbInbound) {
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: inbound.listen,
port: inbound.port,
protocol: inbound.protocol,
settings: inbound.settings.toString(),
};
if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
}
data.sniffing = inbound.sniffing.toString();
const formData = new FormData();
Object.keys(data).forEach(key => formData.append(key, data[key]));
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, formData, inModal);
},
openAddClient(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
clientModal.show({
title: '{{ i18n "pages.client.add"}}',
okText: '{{ i18n "pages.client.submitAdd"}}',
dbInbound: dbInbound,
confirm: async (clients, dbInboundId) => {
await this.addClient(clients, dbInboundId, clientModal);
},
isEdit: false
});
},
openAddBulkClient(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
clientsBulkModal.show({
title: '{{ i18n "pages.client.bulk"}} ' + dbInbound.remark,
okText: '{{ i18n "pages.client.bulk"}}',
dbInbound: dbInbound,
confirm: async (clients, dbInboundId) => {
await this.addClient(clients, dbInboundId, clientsBulkModal);
},
});
},
openEditClient(dbInboundId, client) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
if (!dbInbound) return;
clients = this.getInboundClients(dbInbound);
if (!clients || !Array.isArray(clients)) return;
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
if (index < 0) return;
clientModal.show({
title: '{{ i18n "pages.client.edit"}}',
okText: '{{ i18n "pages.client.submitEdit"}}',
dbInbound: dbInbound,
index: index,
confirm: async (client, dbInboundId, clientId) => {
clientModal.loading();
await this.updateClient(client, dbInboundId, clientId);
clientModal.close();
},
isEdit: true
});
},
findIndexOfClient(protocol, clients, client) {
if (!clients || !Array.isArray(clients) || !client) {
return -1;
}
switch (protocol) {
case Protocols.TROJAN:
case Protocols.SHADOWSOCKS:
return clients.findIndex(item => item && item.password === client.password && item.email === client.email);
default: return clients.findIndex(item => item && item.id === client.id && item.email === client.email);
}
},
async addClient(clients, dbInboundId, modal) {
const data = {
id: dbInboundId,
settings: '{"clients": [' + clients.toString() + ']}',
};
await this.submit(`/panel/api/inbounds/addClient`, data, modal);
},
async updateClient(client, dbInboundId, clientId) {
const data = {
id: dbInboundId,
settings: '{"clients": [' + client.toString() + ']}',
};
await this.submit(`/panel/api/inbounds/updateClient/${clientId}`, data, clientModal);
},
resetTraffic(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
this.$confirm({
title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' #' + dbInboundId,
content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => {
const inbound = dbInbound.toInbound();
dbInbound.up = 0;
dbInbound.down = 0;
this.updateInbound(inbound, dbInbound);
},
});
},
delInbound(dbInboundId) {
this.$confirm({
title: '{{ i18n "pages.inbounds.deleteInbound"}}' + ' #' + dbInboundId,
content: '{{ i18n "pages.inbounds.deleteInboundContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "delete"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/del/' + dbInboundId),
});
},
delClient(dbInboundId, client, confirmation = true) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
clientId = this.getClientId(dbInbound.protocol, client);
if (confirmation) {
this.$confirm({
title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + client.email,
content: '{{ i18n "pages.inbounds.deleteClientContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "delete"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`),
});
} else {
this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`);
}
},
getSubGroupClients(dbInbounds, currentClient) {
const response = {
inbounds: [],
clients: [],
editIds: []
}
if (dbInbounds && dbInbounds.length > 0 && currentClient) {
dbInbounds.forEach((dbInboundItem) => {
const dbInbound = new DBInbound(dbInboundItem);
if (dbInbound) {
const inbound = dbInbound.toInbound();
if (inbound) {
const clients = inbound.clients;
if (clients.length > 0) {
clients.forEach((client) => {
if (client['subId'] === currentClient['subId']) {
client['inboundId'] = dbInboundItem.id
client['clientId'] = this.getClientId(dbInbound.protocol, client)
response.inbounds.push(dbInboundItem.id)
response.clients.push(client)
response.editIds.push(client['clientId'])
}
})
}
}
}
})
}
return response;
},
getClientId(protocol, client) {
switch (protocol) {
case Protocols.TROJAN: return client.password;
case Protocols.SHADOWSOCKS: return client.email;
case Protocols.HYSTERIA: return client.auth;
default: return client.id;
}
},
checkFallback(dbInbound) {
newDbInbound = new DBInbound(dbInbound);
if (dbInbound.listen.startsWith("@")) {
rootInbound = this.inbounds.find((i) =>
i.isTcp &&
['trojan', 'vless'].includes(i.protocol) &&
i.settings.fallbacks.find(f => f.dest === dbInbound.listen)
);
if (rootInbound) {
newDbInbound.listen = rootInbound.listen;
newDbInbound.port = rootInbound.port;
newInbound = newDbInbound.toInbound();
newInbound.stream.security = rootInbound.stream.security;
newInbound.stream.tls = rootInbound.stream.tls;
newInbound.stream.externalProxy = rootInbound.stream.externalProxy;
newDbInbound.streamSettings = newInbound.stream.toString();
}
}
return newDbInbound;
},
showQrcode(dbInboundId, client) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
newDbInbound = this.checkFallback(dbInbound);
qrModal.show('{{ i18n "qrCode"}}', newDbInbound, client);
},
showInfo(dbInboundId, client) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
if (!dbInbound) return;
index = 0;
if (dbInbound.isMultiUser()) {
inbound = dbInbound.toInbound();
clients = inbound && inbound.clients ? inbound.clients : null;
if (clients && Array.isArray(clients)) {
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
if (index < 0) index = 0;
}
}
newDbInbound = this.checkFallback(dbInbound);
infoModal.show(newDbInbound, index);
},
// switchEnable toggles inbound.enable through a dedicated lightweight
// endpoint. The previous implementation re-submitted the entire
// inbound settings JSON (every client) just to flip a boolean — on a
// 7000+ client inbound that meant a multi-MB request, an O(N) traffic
// diff and a full xray-config rebuild for every click of the switch.
async switchEnable(dbInboundId, state) {
const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
if (!dbInbound) return;
const previous = dbInbound.enable;
dbInbound.enable = state; // optimistic: UI reflects the click immediately
const formData = new FormData();
formData.append('enable', String(state));
try {
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInboundId}`, formData);
if (!msg || !msg.success) {
dbInbound.enable = previous;
}
} catch (e) {
dbInbound.enable = previous;
}
},
async switchEnableClient(dbInboundId, client, state) {
this.loading();
try {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
if (!dbInbound) return;
inbound = dbInbound.toInbound();
clients = inbound && inbound.clients ? inbound.clients : null;
if (!clients || !Array.isArray(clients)) return;
index = this.findIndexOfClient(dbInbound.protocol, clients, client);
if (index < 0 || !clients[index]) return;
clients[index].enable = typeof state === 'boolean' ? state : !!client.enable;
clientId = this.getClientId(dbInbound.protocol, clients[index]);
await this.updateClient(clients[index], dbInboundId, clientId);
} finally {
this.loading(false);
}
},
async submit(url, data, modal) {
const msg = await HttpUtil.postWithModal(url, data, modal);
if (msg.success) {
await this.getDBInbounds();
}
},
getInboundClients(dbInbound) {
if (!dbInbound) return null;
const inbound = dbInbound.toInbound();
return inbound && inbound.clients ? inbound.clients : null;
},
resetClientTraffic(client, dbInboundId, confirmation = true) {
if (confirmation) {
this.$confirm({
title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client.email),
})
} else {
this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client.email);
}
},
resetAllTraffic() {
this.$confirm({
title: '{{ i18n "pages.inbounds.resetAllTrafficTitle"}}',
content: '{{ i18n "pages.inbounds.resetAllTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/resetAllTraffics'),
});
},
resetAllClientTraffics(dbInboundId) {
this.$confirm({
title: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficTitle"}}' : '{{ i18n "pages.inbounds.resetAllClientTrafficTitle"}}',
content: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficContent"}}' : '{{ i18n "pages.inbounds.resetAllClientTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/resetAllClientTraffics/' + dbInboundId),
})
},
delDepletedClients(dbInboundId) {
this.$confirm({
title: '{{ i18n "pages.inbounds.delDepletedClientsTitle"}}',
content: '{{ i18n "pages.inbounds.delDepletedClientsContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "delete"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: () => this.submit('/panel/api/inbounds/delDepletedClients/' + dbInboundId),
})
},
isExpiry(dbInbound, index) {
return dbInbound.toInbound().isExpiry(index);
},
getClientStats(dbInbound, email) {
if (!dbInbound) return null;
if (!dbInbound._clientStatsMap) {
dbInbound._clientStatsMap = new Map();
if (dbInbound.clientStats && Array.isArray(dbInbound.clientStats)) {
for (const stats of dbInbound.clientStats) {
dbInbound._clientStatsMap.set(stats.email, stats);
}
}
}
return dbInbound._clientStatsMap.get(email);
},
getUpStats(dbInbound, email) {
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
return clientStats ? clientStats.up : 0;
},
getDownStats(dbInbound, email) {
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
return clientStats ? clientStats.down : 0;
},
getSumStats(dbInbound, email) {
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
return clientStats ? clientStats.up + clientStats.down : 0;
},
getAllTimeClient(dbInbound, email) {
if (!email || email.length == 0) return 0;
const clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
// allTime represents cumulative historical usage and must never
// appear smaller than the currently-tracked counters. If a stale
// row drifts below up+down (manual edits, partial migrations) we
// surface the live total instead of the misleading historical one.
const current = (clientStats.up || 0) + (clientStats.down || 0);
const allTime = clientStats.allTime || 0;
return allTime > current ? allTime : current;
},
getRemStats(dbInbound, email) {
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
let remained = clientStats.total - (clientStats.up + clientStats.down);
return remained > 0 ? remained : 0;
},
clientStatsColor(dbInbound, email) {
if (!email || email.length == 0) return ColorUtils.clientUsageColor();
let clientStats = this.getClientStats(dbInbound, email);
return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
},
statsProgress(dbInbound, email) {
if (!email || email.length == 0) return 100;
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
if (clientStats.total == 0) return 100;
return 100 * (clientStats.down + clientStats.up) / clientStats.total;
},
expireProgress(expTime, reset) {
now = new Date().getTime();
remainedSeconds = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
resetSeconds = reset * 86400;
if (remainedSeconds >= resetSeconds) return 0;
return 100 * (1 - (remainedSeconds / resetSeconds));
},
statsExpColor(dbInbound, email) {
if (!email || email.length == 0) return '#7a316f';
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return '#7a316f';
let statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
let expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
switch (true) {
case statsColor == "red" || expColor == "red":
return "#cf3c3c"; // Red
case statsColor == "orange" || expColor == "orange":
return "#f37b24"; // Orange
case statsColor == "green" || expColor == "green":
return "#008771"; // Green
default:
return "#7a316f"; // purple
}
},
isClientEnabled(dbInbound, email) {
let clientStats = dbInbound ? this.getClientStats(dbInbound, email) : null;
return clientStats ? clientStats['enable'] : true;
},
isClientDepleted(dbInbound, email) {
if (!email || !dbInbound) return false;
const stats = this.getClientStats(dbInbound, email);
if (!stats) return false;
const total = stats.total ?? 0;
const used = (stats.up ?? 0) + (stats.down ?? 0);
const hasTotal = total > 0;
const exhausted = hasTotal && used >= total;
const expiryTime = stats.expiryTime ?? 0;
const hasExpiry = expiryTime > 0;
const now = Date.now();
const expired = hasExpiry && expiryTime <= now;
return expired || exhausted;
},
isClientOnline(email) {
return this.onlineClients.includes(email);
},
getLastOnline(email) {
return this.lastOnlineMap[email] || null
},
formatLastOnline(email) {
const ts = this.getLastOnline(email)
if (!ts) return '-'
// Check if IntlUtil is available (may not be loaded yet)
if (typeof IntlUtil !== 'undefined' && IntlUtil.formatDate) {
return IntlUtil.formatDate(ts)
}
// Fallback to simple date formatting if IntlUtil is not available
return new Date(ts).toLocaleString()
},
isRemovable(dbInboundId) {
return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1;
},
inboundLinks(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
newDbInbound = this.checkFallback(dbInbound);
txtModal.show('{{ i18n "pages.inbounds.export"}}', newDbInbound.genInboundLinks(this.remarkModel), newDbInbound.remark);
},
exportSubs(dbInboundId) {
const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
const clients = this.getInboundClients(dbInbound);
let subLinks = []
if (clients != null) {
clients.forEach(c => {
if (c.subId && c.subId.length > 0) {
subLinks.push(this.subSettings.subURI + c.subId)
}
})
}
txtModal.show(
'{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
[...new Set(subLinks)].join('\n'),
dbInbound.remark + "-Subs");
},
importInbound() {
promptModal.open({
title: '{{ i18n "pages.inbounds.importInbound" }}',
type: 'textarea',
value: '',
okText: '{{ i18n "pages.inbounds.import" }}',
confirm: async (dbInboundText) => {
await this.submit('/panel/api/inbounds/import', { data: dbInboundText }, promptModal);
},
});
},
exportAllSubs() {
let subLinks = []
for (const dbInbound of this.dbInbounds) {
const clients = this.getInboundClients(dbInbound);
if (clients != null) {
clients.forEach(c => {
if (c.subId && c.subId.length > 0) {
subLinks.push(this.subSettings.subURI + c.subId)
}
})
}
}
txtModal.show(
'{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
[...new Set(subLinks)].join('\r\n'),
'All-Inbounds-Subs');
},
exportAllLinks() {
let copyText = [];
for (const dbInbound of this.dbInbounds) {
copyText.push(dbInbound.genInboundLinks(this.remarkModel));
}
txtModal.show('{{ i18n "pages.inbounds.export"}}', copyText.join('\r\n'), 'All-Inbounds');
},
copy(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
txtModal.show('{{ i18n "pages.inbounds.inboundData" }}', JSON.stringify(dbInbound, null, 2));
},
async startDataRefreshLoop() {
while (this.isRefreshEnabled) {
try {
await this.getDBInbounds();
} catch (e) {
console.error(e);
}
await PromiseUtil.sleep(this.refreshInterval);
}
},
toggleRefresh() {
localStorage.setItem("isRefreshEnabled", this.isRefreshEnabled);
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
},
changeRefreshInterval() {
localStorage.setItem("refreshInterval", this.refreshInterval);
},
async manualRefresh() {
if (!this.refreshing) {
this.loadingStates.spinning = true;
await this.getDBInbounds();
this.loadingStates.spinning = false;
}
},
pagination(obj) {
if (this.pageSize > 0 && obj.length > this.pageSize) {
// Set page options based on object size
let sizeOptions = [this.pageSize.toString()];
const increments = [2, 5, 10, 20];
for (const m of increments) {
const val = this.pageSize * m;
if (val < obj.length && val <= 1000) {
sizeOptions.push(val.toString());
}
}
// Add option to see all in one page
if (!sizeOptions.includes(obj.length.toString())) {
sizeOptions.push(obj.length.toString());
}
p = {
showSizeChanger: true,
size: 'small',
position: 'bottom',
pageSize: this.pageSize,
pageSizeOptions: sizeOptions
};
return p;
}
return false
}
},
watch: {
searchKey: Utils.debounce(function (newVal) {
this.searchInbounds(newVal);
}, 500)
},
mounted() {
if (window.location.protocol !== "https:") {
this.showAlert = true;
}
this.loading();
this.getDefaultSettings();
// Bootstrap from REST first, then attach WebSocket subscriptions.
// Doing this in order eliminates a race where an early `inbounds` push
// fires getClientCounts() before this.onlineClients is populated,
// leaving online[] empty for every inbound and breaking the filter.
this.getDBInbounds().then(() => {
this.loading(false);
if (!window.wsClient) {
// Fallback to polling if WebSocket is not available
if (this.isRefreshEnabled) this.startDataRefreshLoop();
return;
}
window.wsClient.connect();
// Listen for inbounds updates
window.wsClient.on('inbounds', (payload) => {
if (payload && Array.isArray(payload)) {
// Use setInbounds to properly convert to DBInbound objects with methods
this.setInbounds(payload);
}
});
// Listen for invalidate signals — last-resort safety only.
// Under normal operation the server pushes 'client_stats' deltas
// instead, so this fires only when an admin mutation produces an
// oversized full-list payload.
let invalidateTimer = null;
window.wsClient.on('invalidate', (payload) => {
if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) {
if (invalidateTimer) clearTimeout(invalidateTimer);
invalidateTimer = setTimeout(() => {
invalidateTimer = null;
this.getDBInbounds();
}, 1000);
}
});
// Real-time delta updates: per-client absolute counters + inbound
// totals applied in-place. Replaces the periodic full-list refresh
// and scales to 10k+ clients without REST fallback.
window.wsClient.on('client_stats', (payload) => {
if (!payload) return;
this.applyClientStatsDelta(payload);
});
// Listen for traffic updates.
// Note: clientTraffics contains DELTA values (incremental since last
// tick), not absolute totals. Absolute counters are updated through
// the 'client_stats' event in applyClientStatsDelta.
window.wsClient.on('traffic', (payload) => {
if (!payload || typeof payload !== 'object') return;
// Normalize onlineClients: server marshals a nil []string slice as
// JSON null when nobody is online. Treat null/undefined/missing as
// an empty array so the "everyone went offline" transition still
// updates the UI — without this fix, the last set of online users
// stayed visible (and the online filter kept showing them) until
// someone came back online.
const hasOnlinePayload =
'onlineClients' in payload &&
(Array.isArray(payload.onlineClients) || payload.onlineClients == null);
if (hasOnlinePayload) {
const nextOnlineClients = Array.isArray(payload.onlineClients)
? payload.onlineClients
: [];
// Detect change in either direction: length differs OR sets differ.
let onlineChanged = this.onlineClients.length !== nextOnlineClients.length;
if (!onlineChanged) {
const prevSet = new Set(this.onlineClients);
for (const email of nextOnlineClients) {
if (!prevSet.has(email)) {
onlineChanged = true;
break;
}
}
}
this.onlineClients = nextOnlineClients;
if (onlineChanged) {
// Recompute clientCount for every inbound whose stats can host
// online clients. `dbInbound.toInbound()` returns the cached
// parsed Inbound (with the .clients array) — using it directly
// avoids a brittle `this.inbounds.find(ib => ib.id === ...)`
// lookup that ALWAYS failed because the Inbound class has no
// `id` field. That silent failure was the real cause of the
// online filter showing an empty list while a client was
// clearly online elsewhere on the page.
this.dbInbounds.forEach(dbInbound => {
const inbound = dbInbound.toInbound();
this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
});
// Re-run filter/search so the UI reflects the new state — both
// when clients come online and when they go offline.
if (this.enableFilter) {
this.filterInbounds();
} else {
this.searchInbounds(this.searchKey);
}
}
}
// Update last-online map. Server sends the full map (not delta) so
// we can replace entirely without growing unbounded from deleted clients.
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
this.lastOnlineMap = payload.lastOnlineMap;
}
});
// Fallback to polling if WebSocket fails
window.wsClient.on('error', () => {
console.warn('WebSocket connection failed, falling back to polling');
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
});
window.wsClient.on('disconnected', () => {
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
console.warn('WebSocket reconnection failed, falling back to polling');
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
}
});
});
},
computed: {
total() {
let down = 0, up = 0, allTime = 0;
let clients = 0, deactive = [], depleted = [], expiring = [];
this.dbInbounds.forEach(dbInbound => {
down += dbInbound.down;
up += dbInbound.up;
allTime += (dbInbound.allTime || (dbInbound.up + dbInbound.down));
if (this.clientCount[dbInbound.id]) {
clients += this.clientCount[dbInbound.id].clients;
deactive = deactive.concat(this.clientCount[dbInbound.id].deactive);
depleted = depleted.concat(this.clientCount[dbInbound.id].depleted);
expiring = expiring.concat(this.clientCount[dbInbound.id].expiring);
}
});
return {
down: down,
up: up,
allTime: allTime,
clients: clients,
deactive: deactive,
depleted: depleted,
expiring: expiring,
};
}
},
});
</script>
<style>
#content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
position: fixed !important;
top: 50vh !important;
left: calc(50vw + 100px) !important;
transform: translate(-50%, -50%);
z-index: 99999 !important;
}
@media (max-width: 768px) {
#content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
left: 50vw !important;
}
}
/* Protocol cell — wrap tags into a flex grid with consistent gap so
vless/xhttp/Reality line up cleanly instead of stacking awkwardly. */
.inbounds-page .protocol-tags {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
max-width: 100%;
}
.inbounds-page .protocol-tags .ant-tag {
margin: 0;
line-height: 20px;
}
/* Traffic cell — text on the sides sizes to its content, the progress bar
takes whatever's left. Without this, fixed-width text cells leave gaps
around short values like "∞" and clip long ones like "999.99 GB". */
.inbounds-page .tr-table-box {
display: inline-flex;
gap: 6px;
align-items: center;
width: 100%;
min-width: 0;
}
.inbounds-page .tr-table-rt,
.inbounds-page .tr-table-lt {
flex: 0 0 auto;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.inbounds-page .tr-table-rt { text-align: end; }
.inbounds-page .tr-table-lt { text-align: start; }
.inbounds-page .tr-table-bar {
flex: 1 1 auto;
min-width: 60px;
}
/* Make the progress widget fill its flex cell, and align the inner fill
pill with the outer track pill (the "two pills" drift was caused by
box-sizing: content-box plus a 1px border on .ant-progress-bg). */
.inbounds-page .tr-table-bar .ant-progress,
.inbounds-page .tr-table-bar .ant-progress-outer,
.inbounds-page .tr-table-bar .ant-progress-inner {
display: block;
width: 100%;
margin: 0;
padding: 0;
}
.inbounds-page .infinite-bar .ant-progress-inner,
.inbounds-page .tr-table-bar .ant-progress-inner {
box-sizing: border-box;
border-radius: 100px;
overflow: hidden;
}
.inbounds-page .infinite-bar .ant-progress-inner .ant-progress-bg,
.inbounds-page .tr-table-bar .ant-progress-inner .ant-progress-bg {
box-sizing: border-box;
border: 0 !important;
}
</style>
{{ template "page/body_end" .}}