inbounds.html 104 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356
  1. {{ template "page/head_start" .}}
  2. {{ template "page/head_end" .}}
  3. {{ template "page/body_start" .}}
  4. <a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' inbounds-page'">
  5. <a-sidebar></a-sidebar>
  6. <a-layout id="content-layout">
  7. <a-layout-content>
  8. <a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}' size="large">
  9. <transition name="list" appear>
  10. <a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
  11. message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
  12. </a-alert>
  13. </transition>
  14. <transition name="list" appear>
  15. <a-row v-if="!loadingStates.fetched">
  16. <div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
  17. </a-row>
  18. <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
  19. <a-col>
  20. <a-card size="small" :style="{ padding: '16px' }" hoverable>
  21. <a-row>
  22. <a-col :sm="12" :md="5">
  23. <a-custom-statistic title='{{ i18n "pages.inbounds.totalDownUp" }}'
  24. :value="`${SizeFormatter.sizeFormat(total.up)} / ${SizeFormatter.sizeFormat(total.down)}`">
  25. <template #prefix>
  26. <a-icon type="swap"></a-icon>
  27. </template>
  28. </a-custom-statistic>
  29. </a-col>
  30. <a-col :sm="12" :md="5">
  31. <a-custom-statistic title='{{ i18n "pages.inbounds.totalUsage" }}'
  32. :value="SizeFormatter.sizeFormat(total.up + total.down)"
  33. :style="{ marginTop: isMobile ? '10px' : 0 }">
  34. <template #prefix>
  35. <a-icon type="pie-chart"></a-icon>
  36. </template>
  37. </a-custom-statistic>
  38. </a-col>
  39. <a-col :sm="12" :md="5">
  40. <a-custom-statistic title='{{ i18n "pages.inbounds.allTimeTrafficUsage" }}'
  41. :value="SizeFormatter.sizeFormat(total.allTime)" :style="{ marginTop: isMobile ? '10px' : 0 }">
  42. <template #prefix>
  43. <a-icon type="history"></a-icon>
  44. </template>
  45. </a-custom-statistic>
  46. </a-col>
  47. <a-col :sm="12" :md="5">
  48. <a-custom-statistic title='{{ i18n "pages.inbounds.inboundCount" }}' :value="dbInbounds.length"
  49. :style="{ marginTop: isMobile ? '10px' : 0 }">
  50. <template #prefix>
  51. <a-icon type="bars"></a-icon>
  52. </template>
  53. </a-custom-statistic>
  54. </a-col>
  55. <a-col :sm="12" :md="4">
  56. <a-custom-statistic title='{{ i18n "clients" }}' value=" "
  57. :style="{ marginTop: isMobile ? '10px' : 0 }">
  58. <template #prefix>
  59. <a-space direction="horizontal">
  60. <a-icon type="team"></a-icon>
  61. <div>
  62. <a-back-top :target="() => document.getElementById('content-layout')"
  63. visibility-height="200"></a-back-top>
  64. <a-tag color="green">[[ total.clients ]]</a-tag>
  65. <a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
  66. <template slot="content">
  67. <div v-for="clientEmail in total.deactive"><span>[[
  68. clientEmail ]]</span></div>
  69. </template>
  70. <a-tag v-if="total.deactive.length">[[
  71. total.deactive.length ]]</a-tag>
  72. </a-popover>
  73. <a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme">
  74. <template slot="content">
  75. <div v-for="clientEmail in total.depleted"><span>[[
  76. clientEmail ]]</span></div>
  77. </template>
  78. <a-tag color="red" v-if="total.depleted.length">[[
  79. total.depleted.length ]]</a-tag>
  80. </a-popover>
  81. <a-popover title='{{ i18n "depletingSoon" }}'
  82. :overlay-class-name="themeSwitcher.currentTheme">
  83. <template slot="content">
  84. <div v-for="clientEmail in total.expiring"><span>[[
  85. clientEmail ]]</span></div>
  86. </template>
  87. <a-tag color="orange" v-if="total.expiring.length">[[
  88. total.expiring.length ]]</a-tag>
  89. </a-popover>
  90. <a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
  91. <template slot="content">
  92. <div v-for="clientEmail in onlineClients"><span>[[
  93. clientEmail ]]</span></div>
  94. </template>
  95. <a-tag color="blue" v-if="onlineClients.length">[[
  96. onlineClients.length ]]</a-tag>
  97. </a-popover>
  98. </div>
  99. </a-space>
  100. </template>
  101. </a-custom-statistic>
  102. </a-col>
  103. </a-row>
  104. </a-card>
  105. </a-col>
  106. <a-col>
  107. <a-card hoverable>
  108. <template #title>
  109. <a-space direction="horizontal">
  110. <a-button type="primary" icon="plus" @click="openAddInbound">
  111. <template v-if="!isMobile">{{ i18n
  112. "pages.inbounds.addInbound" }}</template>
  113. </a-button>
  114. <a-dropdown :trigger="['click']">
  115. <a-button type="primary" icon="menu">
  116. <template v-if="!isMobile">{{ i18n
  117. "pages.inbounds.generalActions" }}</template>
  118. </a-button>
  119. <a-menu slot="overlay" @click="a => generalActions(a)" :theme="themeSwitcher.currentTheme">
  120. <a-menu-item key="import">
  121. <a-icon type="import"></a-icon>
  122. {{ i18n "pages.inbounds.importInbound" }}
  123. </a-menu-item>
  124. <a-menu-item key="export">
  125. <a-icon type="export"></a-icon>
  126. {{ i18n "pages.inbounds.export" }}
  127. </a-menu-item>
  128. <a-menu-item key="subs" v-if="subSettings.enable">
  129. <a-icon type="export"></a-icon>
  130. {{ i18n "pages.inbounds.export" }} - {{ i18n
  131. "pages.settings.subSettings" }}
  132. </a-menu-item>
  133. <a-menu-item key="resetInbounds">
  134. <a-icon type="reload"></a-icon>
  135. {{ i18n "pages.inbounds.resetAllTraffic" }}
  136. </a-menu-item>
  137. <a-menu-item key="resetClients">
  138. <a-icon type="file-done"></a-icon>
  139. {{ i18n "pages.inbounds.resetAllClientTraffics" }}
  140. </a-menu-item>
  141. <a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
  142. <a-icon type="rest"></a-icon>
  143. {{ i18n "pages.inbounds.delDepletedClients" }}
  144. </a-menu-item>
  145. </a-menu>
  146. </a-dropdown>
  147. </a-space>
  148. </template>
  149. <template #extra>
  150. <a-button-group>
  151. <a-button icon="sync" @click="manualRefresh" :loading="refreshing"></a-button>
  152. <a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme">
  153. <template #title>
  154. <div class="ant-custom-popover-title">
  155. <a-switch v-model="isRefreshEnabled" @change="toggleRefresh" size="small"></a-switch>
  156. <span>{{ i18n "pages.inbounds.autoRefresh" }}</span>
  157. </div>
  158. </template>
  159. <template #content>
  160. <a-space direction="vertical">
  161. <span>{{ i18n "pages.inbounds.autoRefreshInterval"
  162. }}</span>
  163. <a-select v-model="refreshInterval" :disabled="!isRefreshEnabled" :style="{ width: '100%' }"
  164. @change="changeRefreshInterval" :dropdown-class-name="themeSwitcher.currentTheme">
  165. <a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option>
  166. </a-select>
  167. </a-space>
  168. </template>
  169. <a-button icon="down"></a-button>
  170. </a-popover>
  171. </a-button-group>
  172. </template>
  173. <a-space direction="vertical">
  174. <div :style="isMobile ? {} : { display: 'flex', alignItems: 'center', justifyContent: 'flex-start' }">
  175. <a-switch v-model="enableFilter"
  176. :style="isMobile ? { marginBottom: '.5rem', display: 'flex' } : { marginRight: '.5rem' }"
  177. @change="toggleFilter">
  178. <a-icon slot="checkedChildren" type="search"></a-icon>
  179. <a-icon slot="unCheckedChildren" type="filter"></a-icon>
  180. </a-switch>
  181. <a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus
  182. :style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
  183. <a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterInbounds" button-style="solid"
  184. class="mobile-filter-group"
  185. :size="isMobile ? 'small' : ''">
  186. <a-radio-button value>{{ i18n "none" }}</a-radio-button>
  187. <a-radio-button value="active">{{ i18n "subscription.active"
  188. }}</a-radio-button>
  189. <a-radio-button value="deactive">{{ i18n "disabled"
  190. }}</a-radio-button>
  191. <a-radio-button value="depleted">{{ i18n "depleted"
  192. }}</a-radio-button>
  193. <a-radio-button value="expiring">{{ i18n "depletingSoon"
  194. }}</a-radio-button>
  195. <a-radio-button value="online">{{ i18n "online"
  196. }}</a-radio-button>
  197. </a-radio-group>
  198. </div>
  199. <a-table :columns="isMobile ? mobileColumns : columns" :row-key="dbInbound => dbInbound.id"
  200. :data-source="searchedInbounds" :scroll="isMobile ? {} : { x: 1000 }"
  201. :pagination=pagination(searchedInbounds) :expand-icon-as-cell="false" :expand-row-by-click="false"
  202. :expand-icon-column-index="0" :indent-size="0"
  203. :row-class-name="dbInbound => (dbInbound.isMultiUser() ? '' : 'hideExpandIcon')"
  204. :style="{ marginTop: '10px' }"
  205. :locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
  206. <template slot="action" slot-scope="text, dbInbound">
  207. <a-dropdown :trigger="['click']">
  208. <a-icon @click="e => e.preventDefault()" type="more"
  209. :style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
  210. <a-menu slot="overlay" @click="a => clickAction(a, dbInbound)"
  211. :theme="themeSwitcher.currentTheme">
  212. <a-menu-item key="edit">
  213. <a-icon type="edit"></a-icon>
  214. {{ i18n "edit" }}
  215. </a-menu-item>
  216. <a-menu-item key="qrcode"
  217. v-if="(dbInbound.isSS && !dbInbound.toInbound().isSSMultiUser) || dbInbound.isWireguard">
  218. <a-icon type="qrcode"></a-icon>
  219. {{ i18n "qrCode" }}
  220. </a-menu-item>
  221. <template v-if="dbInbound.isMultiUser()">
  222. <a-menu-item key="addClient">
  223. <a-icon type="user-add"></a-icon>
  224. {{ i18n "pages.client.add"}}
  225. </a-menu-item>
  226. <a-menu-item key="addBulkClient">
  227. <a-icon type="usergroup-add"></a-icon>
  228. {{ i18n "pages.client.bulk"}}
  229. </a-menu-item>
  230. <a-menu-item key="copyClients">
  231. <a-icon type="copy"></a-icon>
  232. {{ i18n "pages.client.copyFromInbound"}}
  233. </a-menu-item>
  234. <a-menu-item key="resetClients">
  235. <a-icon type="file-done"></a-icon>
  236. {{ i18n
  237. "pages.inbounds.resetInboundClientTraffics"}}
  238. </a-menu-item>
  239. <a-menu-item key="export">
  240. <a-icon type="export"></a-icon>
  241. {{ i18n "pages.inbounds.export"}}
  242. </a-menu-item>
  243. <a-menu-item key="subs" v-if="subSettings.enable">
  244. <a-icon type="export"></a-icon>
  245. {{ i18n "pages.inbounds.export"}} - {{ i18n
  246. "pages.settings.subSettings" }}
  247. </a-menu-item>
  248. <a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
  249. <a-icon type="rest"></a-icon>
  250. {{ i18n "pages.inbounds.delDepletedClients" }}
  251. </a-menu-item>
  252. </template>
  253. <template v-else>
  254. <a-menu-item key="showInfo">
  255. <a-icon type="info-circle"></a-icon>
  256. {{ i18n "info"}}
  257. </a-menu-item>
  258. </template>
  259. <a-menu-item key="clipboard">
  260. <a-icon type="copy"></a-icon>
  261. {{ i18n "pages.inbounds.exportInbound" }}
  262. </a-menu-item>
  263. <a-menu-item key="resetTraffic">
  264. <a-icon type="retweet"></a-icon> {{ i18n
  265. "pages.inbounds.resetTraffic" }}
  266. </a-menu-item>
  267. <a-menu-item key="clone">
  268. <a-icon type="block"></a-icon> {{ i18n
  269. "pages.inbounds.clone"}}
  270. </a-menu-item>
  271. <a-menu-item key="delete">
  272. <span :style="{ color: '#FF4D4F' }">
  273. <a-icon type="delete"></a-icon> {{ i18n "delete"}}
  274. </span>
  275. </a-menu-item>
  276. <a-menu-item v-if="isMobile">
  277. <a-switch size="small" v-model="dbInbound.enable"
  278. @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
  279. {{ i18n "pages.inbounds.enable" }}
  280. </a-menu-item>
  281. </a-menu>
  282. </a-dropdown>
  283. </template>
  284. <template slot="protocol" slot-scope="text, dbInbound">
  285. <div class="protocol-tags">
  286. <a-tag color="purple">[[ dbInbound.protocol ]]</a-tag>
  287. <template
  288. v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
  289. <a-tag color="green">[[ dbInbound.toInbound().stream.network ]]</a-tag>
  290. <a-tag v-if="dbInbound.toInbound().stream.isTls" color="blue">TLS</a-tag>
  291. <a-tag v-if="dbInbound.toInbound().stream.isReality" color="blue">Reality</a-tag>
  292. </template>
  293. </div>
  294. </template>
  295. <template slot="clients" slot-scope="text, dbInbound">
  296. <template v-if="clientCount[dbInbound.id]">
  297. <a-tag :style="{ margin: '0' }" color="green">[[
  298. clientCount[dbInbound.id].clients ]]</a-tag>
  299. <a-popover title='{{ i18n "disabled" }}' :overlay-class-name="themeSwitcher.currentTheme">
  300. <template slot="content">
  301. <div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail"
  302. class="client-popup-item">
  303. <span>[[ clientEmail ]]</span>
  304. <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
  305. <template #title>
  306. [[
  307. clientCount[dbInbound.id].comments.get(clientEmail)
  308. ]]
  309. </template>
  310. <a-icon type="message"
  311. v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
  312. </a-tooltip>
  313. </div>
  314. </template>
  315. <a-tag :style="{ margin: '0', padding: '0 2px' }"
  316. v-if="clientCount[dbInbound.id].deactive.length">[[
  317. clientCount[dbInbound.id].deactive.length ]]</a-tag>
  318. </a-popover>
  319. <a-popover title='{{ i18n "depleted" }}' :overlay-class-name="themeSwitcher.currentTheme">
  320. <template slot="content">
  321. <div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail"
  322. class="client-popup-item">
  323. <span>[[ clientEmail ]]</span>
  324. <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
  325. <template #title>
  326. [[
  327. clientCount[dbInbound.id].comments.get(clientEmail)
  328. ]]
  329. </template>
  330. <a-icon type="message"
  331. v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
  332. </a-tooltip>
  333. </div>
  334. </template>
  335. <a-tag :style="{ margin: '0', padding: '0 2px' }" color="red"
  336. v-if="clientCount[dbInbound.id].depleted.length">[[
  337. clientCount[dbInbound.id].depleted.length ]]</a-tag>
  338. </a-popover>
  339. <a-popover title='{{ i18n "depletingSoon" }}' :overlay-class-name="themeSwitcher.currentTheme">
  340. <template slot="content">
  341. <div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail"
  342. class="client-popup-item">
  343. <span>[[ clientEmail ]]</span>
  344. <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
  345. <template #title>
  346. [[
  347. clientCount[dbInbound.id].comments.get(clientEmail)
  348. ]]
  349. </template>
  350. <a-icon type="message"
  351. v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
  352. </a-tooltip>
  353. </div>
  354. </template>
  355. <a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange"
  356. v-if="clientCount[dbInbound.id].expiring.length">[[
  357. clientCount[dbInbound.id].expiring.length ]]</a-tag>
  358. </a-popover>
  359. <a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
  360. <template slot="content">
  361. <div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail"
  362. class="client-popup-item">
  363. <span>[[ clientEmail ]]</span>
  364. <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
  365. <template #title>
  366. [[
  367. clientCount[dbInbound.id].comments.get(clientEmail)
  368. ]]
  369. </template>
  370. <a-icon type="message"
  371. v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
  372. </a-tooltip>
  373. </div>
  374. </template>
  375. <a-tag :style="{ margin: '0', padding: '0 2px' }" color="blue"
  376. v-if="clientCount[dbInbound.id].online.length">[[
  377. clientCount[dbInbound.id].online.length
  378. ]]</a-tag>
  379. </a-popover>
  380. </template>
  381. </template>
  382. <template slot="traffic" slot-scope="text, dbInbound">
  383. <a-popover :overlay-class-name="themeSwitcher.currentTheme">
  384. <template slot="content">
  385. <table cellpadding="2" width="100%">
  386. <tr>
  387. <td>↑[[ SizeFormatter.sizeFormat(dbInbound.up)
  388. ]]</td>
  389. <td>↓[[ SizeFormatter.sizeFormat(dbInbound.down)
  390. ]]</td>
  391. </tr>
  392. <tr v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
  393. <td>{{ i18n "remained" }}</td>
  394. <td>[[ SizeFormatter.sizeFormat(dbInbound.total -
  395. dbInbound.up - dbInbound.down) ]]</td>
  396. </tr>
  397. </table>
  398. </template>
  399. <a-tag
  400. :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
  401. [[ SizeFormatter.sizeFormat(dbInbound.up +
  402. dbInbound.down) ]] /
  403. <template v-if="dbInbound.total > 0">
  404. [[ SizeFormatter.sizeFormat(dbInbound.total) ]]
  405. </template>
  406. <template v-else>
  407. <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
  408. <path
  409. 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"
  410. fill="currentColor"></path>
  411. </svg>
  412. </template>
  413. </a-tag>
  414. </a-popover>
  415. </template>
  416. <template slot="allTimeInbound" slot-scope="text, dbInbound">
  417. <a-tag>[[ SizeFormatter.sizeFormat(dbInbound.allTime || 0)
  418. ]]</a-tag>
  419. </template>
  420. <template slot="enable" slot-scope="text, dbInbound">
  421. <a-switch v-model="dbInbound.enable"
  422. @change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
  423. </template>
  424. <template slot="expiryTime" slot-scope="text, dbInbound">
  425. <a-popover v-if="dbInbound.expiryTime > 0" :overlay-class-name="themeSwitcher.currentTheme">
  426. <template slot="content">
  427. [[ IntlUtil.formatDate(dbInbound.expiryTime) ]]
  428. </template>
  429. <a-tag :style="{ minWidth: '50px' }"
  430. :color="ColorUtils.usageColor(new Date().getTime(), app.expireDiff, dbInbound._expiryTime)">
  431. [[ IntlUtil.formatRelativeTime(dbInbound.expiryTime)
  432. ]]
  433. </a-tag>
  434. </a-popover>
  435. <a-tag v-else color="purple" class="infinite-tag">
  436. <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
  437. <path
  438. 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"
  439. fill="currentColor"></path>
  440. </svg>
  441. </a-tag>
  442. </template>
  443. <template slot="info" slot-scope="text, dbInbound">
  444. <a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme"
  445. trigger="click">
  446. <template slot="content">
  447. <table cellpadding="2">
  448. <tr>
  449. <td>{{ i18n "pages.inbounds.protocol" }}</td>
  450. <td>
  451. <a-tag :style="{ margin: '0' }" color="purple">[[ dbInbound.protocol
  452. ]]</a-tag>
  453. <template
  454. v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
  455. <a-tag :style="{ margin: '0' }" color="blue">[[
  456. dbInbound.toInbound().stream.network
  457. ]]</a-tag>
  458. <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls"
  459. color="green">tls</a-tag>
  460. <a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality"
  461. color="green">reality</a-tag>
  462. </template>
  463. </td>
  464. </tr>
  465. <tr>
  466. <td>{{ i18n "pages.inbounds.port" }}</td>
  467. <td><a-tag>[[ dbInbound.port ]]</a-tag></td>
  468. </tr>
  469. <tr v-if="clientCount[dbInbound.id]">
  470. <td>{{ i18n "clients" }}</td>
  471. <td>
  472. <a-tag :style="{ margin: '0' }" color="blue">[[
  473. clientCount[dbInbound.id].clients
  474. ]]</a-tag>
  475. <a-popover title='{{ i18n "disabled" }}'
  476. :overlay-class-name="themeSwitcher.currentTheme">
  477. <template slot="content">
  478. <div v-for="clientEmail in clientCount[dbInbound.id].deactive" :key="clientEmail"
  479. class="client-popup-item">
  480. <span>[[ clientEmail ]]</span>
  481. <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
  482. <template #title>
  483. [[
  484. clientCount[dbInbound.id].comments.get(clientEmail)
  485. ]]
  486. </template>
  487. <a-icon type="message"
  488. v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
  489. </a-tooltip>
  490. </div>
  491. </template>
  492. <a-tag :style="{ margin: '0', padding: '0 2px' }"
  493. v-if="clientCount[dbInbound.id].deactive.length">[[
  494. clientCount[dbInbound.id].deactive.length
  495. ]]</a-tag>
  496. </a-popover>
  497. <a-popover title='{{ i18n "depleted" }}'
  498. :overlay-class-name="themeSwitcher.currentTheme">
  499. <template slot="content">
  500. <div v-for="clientEmail in clientCount[dbInbound.id].depleted" :key="clientEmail"
  501. class="client-popup-item">
  502. <span>[[ clientEmail ]]</span>
  503. <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
  504. <template #title>
  505. [[
  506. clientCount[dbInbound.id].comments.get(clientEmail)
  507. ]]
  508. </template>
  509. <a-icon type="message"
  510. v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
  511. </a-tooltip>
  512. </div>
  513. </template>
  514. <a-tag :style="{ margin: '0', padding: '0 2px' }" color="red"
  515. v-if="clientCount[dbInbound.id].depleted.length">[[
  516. clientCount[dbInbound.id].depleted.length
  517. ]]</a-tag>
  518. </a-popover>
  519. <a-popover title='{{ i18n "depletingSoon" }}'
  520. :overlay-class-name="themeSwitcher.currentTheme">
  521. <template slot="content">
  522. <div v-for="clientEmail in clientCount[dbInbound.id].expiring" :key="clientEmail"
  523. class="client-popup-item">
  524. <span>[[ clientEmail ]]</span>
  525. <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
  526. <template #title>
  527. [[
  528. clientCount[dbInbound.id].comments.get(clientEmail)
  529. ]]
  530. </template>
  531. <a-icon type="message"
  532. v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
  533. </a-tooltip>
  534. </div>
  535. </template>
  536. <a-tag :style="{ margin: '0', padding: '0 2px' }" color="orange"
  537. v-if="clientCount[dbInbound.id].expiring.length">[[
  538. clientCount[dbInbound.id].expiring.length
  539. ]]</a-tag>
  540. </a-popover>
  541. <a-popover title='{{ i18n "online" }}' :overlay-class-name="themeSwitcher.currentTheme">
  542. <template slot="content">
  543. <div v-for="clientEmail in clientCount[dbInbound.id].online" :key="clientEmail"
  544. class="client-popup-item">
  545. <span>[[ clientEmail ]]</span>
  546. <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
  547. <template #title>
  548. [[
  549. clientCount[dbInbound.id].comments.get(clientEmail)
  550. ]]
  551. </template>
  552. <a-icon type="message"
  553. v-if="clientCount[dbInbound.id].comments.get(clientEmail)"></a-icon>
  554. </a-tooltip>
  555. </div>
  556. </template>
  557. <a-tag :style="{ margin: '0', padding: '0 2px' }" color="green"
  558. v-if="clientCount[dbInbound.id].online.length">[[
  559. clientCount[dbInbound.id].online.length
  560. ]]</a-tag>
  561. </a-popover>
  562. </td>
  563. </tr>
  564. <tr>
  565. <td>{{ i18n "pages.inbounds.traffic" }}</td>
  566. <td>
  567. <a-popover :overlay-class-name="themeSwitcher.currentTheme">
  568. <template slot="content">
  569. <table cellpadding="2" width="100%">
  570. <tr>
  571. <td>↑[[
  572. SizeFormatter.sizeFormat(dbInbound.up)
  573. ]]</td>
  574. <td>↓[[
  575. SizeFormatter.sizeFormat(dbInbound.down)
  576. ]]</td>
  577. </tr>
  578. <tr
  579. v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
  580. <td>{{ i18n "remained" }}</td>
  581. <td>[[
  582. SizeFormatter.sizeFormat(dbInbound.total
  583. - dbInbound.up - dbInbound.down)
  584. ]]</td>
  585. </tr>
  586. </table>
  587. </template>
  588. <a-tag
  589. :color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
  590. [[ SizeFormatter.sizeFormat(dbInbound.up +
  591. dbInbound.down) ]] /
  592. <template v-if="dbInbound.total > 0">
  593. [[
  594. SizeFormatter.sizeFormat(dbInbound.total)
  595. ]]
  596. </template>
  597. <template v-else>
  598. <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
  599. <path
  600. 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"
  601. fill="currentColor"></path>
  602. </svg>
  603. </template>
  604. </a-tag>
  605. </a-popover>
  606. </td>
  607. </tr>
  608. <tr>
  609. <td>{{ i18n "pages.inbounds.expireDate" }}</td>
  610. <td>
  611. <a-tag :style="{ minWidth: '50px', textAlign: 'center' }"
  612. v-if="dbInbound.expiryTime > 0" :color="dbInbound.isExpiry? 'red': 'blue'">
  613. [[ IntlUtil.formatDate(dbInbound.expiryTime)
  614. ]]
  615. </a-tag>
  616. <a-tag v-else :style="{ textAlign: 'center' }" color="purple" class="infinite-tag">
  617. <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
  618. <path
  619. 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"
  620. fill="currentColor"></path>
  621. </svg>
  622. </a-tag>
  623. </td>
  624. </tr>
  625. <tr>
  626. <td>{{ i18n
  627. "pages.inbounds.periodicTrafficResetTitle"
  628. }}</td>
  629. <td>
  630. <a-tag color="blue">[[ dbInbound.trafficReset
  631. ]]</a-tag>
  632. </td>
  633. </tr>
  634. </table>
  635. </template>
  636. <a-badge>
  637. <a-icon v-if="!dbInbound.enable" slot="count" type="pause-circle"
  638. :style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
  639. <a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
  640. <a-icon type="info"></a-icon>
  641. </a-button>
  642. </a-badge>
  643. </a-popover>
  644. </template>
  645. <template slot="expandedRowRender" slot-scope="record">
  646. <a-table :row-key="client => client.id"
  647. :columns="isMobile ? innerMobileColumns : innerColumns"
  648. :data-source="getInboundClients(record)"
  649. :pagination=pagination(getInboundClients(record))
  650. :scroll="isMobile ? {} : { x: 'max-content' }"
  651. :style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -21px` }">
  652. {{template "component/aClientTable" .}}
  653. </a-table>
  654. </template>
  655. </a-table>
  656. </a-space>
  657. </a-card>
  658. </a-col>
  659. </a-row>
  660. </transition>
  661. </a-spin>
  662. </a-layout-content>
  663. </a-layout>
  664. </a-layout>
  665. {{template "page/body_scripts" .}}
  666. <script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
  667. <script src="{{ .base_path }}assets/uri/URI.min.js?{{ .cur_ver }}"></script>
  668. <script src="{{ .base_path }}assets/js/model/reality_targets.js?{{ .cur_ver }}"></script>
  669. <script src="{{ .base_path }}assets/js/model/inbound.js?{{ .cur_ver }}"></script>
  670. <script src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script>
  671. {{template "component/aSidebar" .}}
  672. {{template "component/aThemeSwitch" .}}
  673. {{template "component/aCustomStatistic" .}}
  674. {{template "component/aPersianDatepicker" .}}
  675. {{template "modals/inboundModal" .}}
  676. {{template "modals/promptModal" .}}
  677. {{template "modals/qrcodeModal" .}}
  678. {{template "modals/textModal" .}}
  679. {{template "modals/inboundInfoModal" .}}
  680. {{template "modals/clientsModal" .}}
  681. {{template "modals/clientsBulkModal" .}}
  682. <a-modal id="copy-clients-modal" :title="copyClientsModal.title" :visible="copyClientsModal.visible"
  683. :confirm-loading="copyClientsModal.confirmLoading" ok-text='{{ i18n "pages.client.copySelected" }}'
  684. cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme" :closable="true" :mask-closable="false"
  685. @ok="() => copyClientsModal.ok()" @cancel="() => copyClientsModal.close()" width="900px">
  686. <a-space direction="vertical" style="width: 100%;">
  687. <div>
  688. <div style="margin-bottom: 6px;">{{ i18n "pages.client.copySource" }}</div>
  689. <a-select v-model="copyClientsModal.sourceInboundId" style="width: 100%;"
  690. :dropdown-class-name="themeSwitcher.currentTheme" @change="id => copyClientsModal.onSourceChange(id)">
  691. <a-select-option v-for="item in copyClientsModal.sources" :key="item.id" :value="item.id">
  692. [[ item.label ]]
  693. </a-select-option>
  694. </a-select>
  695. </div>
  696. <div v-if="copyClientsModal.sourceInboundId">
  697. <a-space style="margin-bottom: 10px;">
  698. <a-button size="small"
  699. @click="() => copyClientsModal.selectAll()">{{ i18n "pages.client.selectAll" }}</a-button>
  700. <a-button size="small" @click="() => copyClientsModal.clearAll()">{{ i18n "pages.client.clearAll" }}</a-button>
  701. </a-space>
  702. <a-table :columns="copyClientsColumns" :data-source="copyClientsModal.sourceClients" :pagination="false"
  703. size="small" :row-key="item => item.email" :scroll="{ y: 280 }">
  704. <template slot="emailCheckbox" slot-scope="text, record">
  705. <a-checkbox :checked="copyClientsModal.selectedEmails.includes(record.email)"
  706. @change="event => copyClientsModal.toggleEmail(record.email, event.target.checked)">
  707. [[ record.email ]]
  708. </a-checkbox>
  709. </template>
  710. </a-table>
  711. </div>
  712. <div v-if="copyClientsModal.showFlow">
  713. <div style="margin-bottom: 6px;">{{ i18n "pages.client.copyFlowLabel" }}</div>
  714. <a-select v-model="copyClientsModal.flow" style="width: 100%;" :dropdown-class-name="themeSwitcher.currentTheme"
  715. allow-clear>
  716. <a-select-option value="">{{ i18n "none" }}</a-select-option>
  717. <a-select-option value="xtls-rprx-vision">xtls-rprx-vision</a-select-option>
  718. <a-select-option value="xtls-rprx-vision-udp443">xtls-rprx-vision-udp443</a-select-option>
  719. </a-select>
  720. <div style="margin-top: 4px; font-size: 12px; opacity: 0.7;">
  721. {{ i18n "pages.client.copyFlowHint" }}
  722. </div>
  723. </div>
  724. <div v-if="copyClientsModal.selectedEmails.length > 0">
  725. <div style="margin-bottom: 4px;">{{ i18n "pages.client.copyEmailPreview" }}</div>
  726. <div style="max-height: 120px; overflow-y: auto;">
  727. <a-tag v-for="preview in previewEmails" :key="preview" style="margin-bottom: 4px;">
  728. [[ preview ]]
  729. </a-tag>
  730. </div>
  731. </div>
  732. </a-space>
  733. </a-modal>
  734. <script>
  735. const copyClientsColumns = [{
  736. title: '{{ i18n "pages.inbounds.email" }}',
  737. width: 300,
  738. scopedSlots: {
  739. customRender: 'emailCheckbox'
  740. }
  741. },
  742. {
  743. title: '{{ i18n "pages.inbounds.traffic" }}',
  744. width: 160,
  745. dataIndex: 'trafficLabel'
  746. },
  747. {
  748. title: '{{ i18n "pages.inbounds.expireDate" }}',
  749. width: 180,
  750. dataIndex: 'expiryLabel'
  751. },
  752. ];
  753. const copyClientsModal = {
  754. visible: false,
  755. confirmLoading: false,
  756. title: '',
  757. targetInboundId: 0,
  758. targetInboundRemark: '',
  759. targetProtocol: '',
  760. showFlow: false,
  761. flow: '',
  762. sourceInboundId: undefined,
  763. sources: [],
  764. sourceClients: [],
  765. selectedEmails: [],
  766. show(targetDbInbound) {
  767. if (!targetDbInbound) return;
  768. const sources = app.dbInbounds
  769. .filter(row => row.id !== targetDbInbound.id && typeof row.isMultiUser === 'function' && row.isMultiUser())
  770. .map(row => {
  771. const clients = app.getInboundClients(row) || [];
  772. return {
  773. id: row.id,
  774. label: `${row.remark} (${row.protocol}, ${clients.length})`
  775. };
  776. });
  777. let showFlow = false;
  778. try {
  779. const targetInbound = targetDbInbound.toInbound();
  780. showFlow = !!(targetInbound && typeof targetInbound.canEnableTlsFlow === 'function' && targetInbound
  781. .canEnableTlsFlow());
  782. } catch (e) {
  783. showFlow = false;
  784. }
  785. copyClientsModal.targetInboundId = targetDbInbound.id;
  786. copyClientsModal.targetInboundRemark = targetDbInbound.remark;
  787. copyClientsModal.targetProtocol = targetDbInbound.protocol;
  788. copyClientsModal.showFlow = showFlow;
  789. copyClientsModal.flow = '';
  790. copyClientsModal.title = `{{ i18n "pages.client.copyToInbound" }} ${targetDbInbound.remark}`;
  791. copyClientsModal.sources = sources;
  792. copyClientsModal.sourceInboundId = undefined;
  793. copyClientsModal.sourceClients = [];
  794. copyClientsModal.selectedEmails = [];
  795. copyClientsModal.confirmLoading = false;
  796. copyClientsModal.visible = true;
  797. },
  798. close() {
  799. copyClientsModal.visible = false;
  800. copyClientsModal.confirmLoading = false;
  801. },
  802. onSourceChange(sourceInboundId) {
  803. copyClientsModal.selectedEmails = [];
  804. const sourceInbound = app.dbInbounds.find(row => row.id === Number(sourceInboundId));
  805. if (!sourceInbound) {
  806. copyClientsModal.sourceClients = [];
  807. return;
  808. }
  809. const sourceClients = app.getInboundClients(sourceInbound) || [];
  810. copyClientsModal.sourceClients = sourceClients.map(client => {
  811. const stats = app.getClientStats(sourceInbound, client.email);
  812. const used = stats ? ((stats.up || 0) + (stats.down || 0)) : 0;
  813. let expiryLabel = '{{ i18n "unlimited" }}';
  814. if (client.expiryTime > 0) {
  815. expiryLabel = IntlUtil.formatDate(client.expiryTime);
  816. } else if (client.expiryTime < 0) {
  817. expiryLabel = `${-client.expiryTime / 86400000}d`;
  818. }
  819. return {
  820. email: client.email,
  821. trafficLabel: SizeFormatter.sizeFormat(used),
  822. expiryLabel,
  823. };
  824. });
  825. },
  826. toggleEmail(email, checked) {
  827. const selected = copyClientsModal.selectedEmails.slice();
  828. if (checked) {
  829. if (!selected.includes(email)) selected.push(email);
  830. } else {
  831. const idx = selected.indexOf(email);
  832. if (idx >= 0) selected.splice(idx, 1);
  833. }
  834. copyClientsModal.selectedEmails = selected;
  835. },
  836. selectAll() {
  837. copyClientsModal.selectedEmails = copyClientsModal.sourceClients.map(item => item.email);
  838. },
  839. clearAll() {
  840. copyClientsModal.selectedEmails = [];
  841. },
  842. async ok() {
  843. if (!copyClientsModal.sourceInboundId) {
  844. app.$message.error('{{ i18n "pages.client.copySelectSourceFirst" }}');
  845. return;
  846. }
  847. copyClientsModal.confirmLoading = true;
  848. const payload = {
  849. sourceInboundId: copyClientsModal.sourceInboundId,
  850. clientEmails: copyClientsModal.selectedEmails,
  851. };
  852. if (copyClientsModal.showFlow && copyClientsModal.flow) {
  853. payload.flow = copyClientsModal.flow;
  854. }
  855. try {
  856. const msg = await HttpUtil.post(`/panel/api/inbounds/${copyClientsModal.targetInboundId}/copyClients`,
  857. payload);
  858. if (!msg || !msg.success) return;
  859. const obj = msg.obj || {};
  860. const addedCount = (obj.added || []).length;
  861. const errorList = obj.errors || [];
  862. if (addedCount > 0) {
  863. app.$message.success(`{{ i18n "pages.client.copyResultSuccess" }}: ${addedCount}`);
  864. } else {
  865. app.$message.warning('{{ i18n "pages.client.copyResultNone" }}');
  866. }
  867. if (errorList.length > 0) {
  868. app.$message.error(`{{ i18n "pages.client.copyResultErrors" }}: ${errorList.join('; ')}`);
  869. }
  870. copyClientsModal.close();
  871. await app.getDBInbounds();
  872. } finally {
  873. copyClientsModal.confirmLoading = false;
  874. }
  875. },
  876. };
  877. const copyClientsModalApp = new Vue({
  878. delimiters: ['[[', ']]'],
  879. el: '#copy-clients-modal',
  880. data: {
  881. copyClientsModal,
  882. copyClientsColumns,
  883. themeSwitcher,
  884. },
  885. computed: {
  886. previewEmails() {
  887. if (!this.copyClientsModal.targetInboundId) return [];
  888. return this.copyClientsModal.selectedEmails.map(email =>
  889. `${email}_${this.copyClientsModal.targetInboundId}`);
  890. },
  891. },
  892. });
  893. </script>
  894. <script>
  895. const columns = [{
  896. title: "ID",
  897. align: 'right',
  898. dataIndex: "id",
  899. width: 30,
  900. responsive: ["xs"],
  901. }, {
  902. title: '{{ i18n "pages.inbounds.operate" }}',
  903. align: 'center',
  904. width: 30,
  905. scopedSlots: {
  906. customRender: 'action'
  907. },
  908. }, {
  909. title: '{{ i18n "pages.inbounds.enable" }}',
  910. align: 'center',
  911. width: 35,
  912. scopedSlots: {
  913. customRender: 'enable'
  914. },
  915. }, {
  916. title: '{{ i18n "pages.inbounds.remark" }}',
  917. align: 'center',
  918. width: 60,
  919. dataIndex: "remark",
  920. }, {
  921. title: '{{ i18n "pages.inbounds.port" }}',
  922. align: 'center',
  923. dataIndex: "port",
  924. width: 40,
  925. }, {
  926. title: '{{ i18n "pages.inbounds.protocol" }}',
  927. align: 'left',
  928. width: 70,
  929. scopedSlots: {
  930. customRender: 'protocol'
  931. },
  932. }, {
  933. title: '{{ i18n "clients" }}',
  934. align: 'left',
  935. width: 50,
  936. scopedSlots: {
  937. customRender: 'clients'
  938. },
  939. }, {
  940. title: '{{ i18n "pages.inbounds.traffic" }}',
  941. align: 'center',
  942. width: 90,
  943. scopedSlots: {
  944. customRender: 'traffic'
  945. },
  946. }, {
  947. title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
  948. align: 'center',
  949. width: 60,
  950. scopedSlots: {
  951. customRender: 'allTimeInbound'
  952. },
  953. }, {
  954. title: '{{ i18n "pages.inbounds.expireDate" }}',
  955. align: 'center',
  956. width: 40,
  957. scopedSlots: {
  958. customRender: 'expiryTime'
  959. },
  960. }];
  961. const mobileColumns = [{
  962. title: "ID",
  963. align: 'right',
  964. dataIndex: "id",
  965. width: 10,
  966. responsive: ["s"],
  967. }, {
  968. title: '{{ i18n "pages.inbounds.operate" }}',
  969. align: 'center',
  970. width: 25,
  971. scopedSlots: {
  972. customRender: 'action'
  973. },
  974. }, {
  975. title: '{{ i18n "pages.inbounds.remark" }}',
  976. align: 'left',
  977. width: 70,
  978. dataIndex: "remark",
  979. }, {
  980. title: '{{ i18n "pages.inbounds.info" }}',
  981. align: 'center',
  982. width: 10,
  983. scopedSlots: {
  984. customRender: 'info'
  985. },
  986. }];
  987. const innerColumns = [
  988. { title: '{{ i18n "pages.inbounds.operate" }}', width: 140, scopedSlots: { customRender: 'actions' } },
  989. { title: '{{ i18n "pages.inbounds.enable" }}', width: 60, scopedSlots: { customRender: 'enable' } },
  990. { title: '{{ i18n "online" }}', width: 80, scopedSlots: { customRender: 'online' } },
  991. { title: '{{ i18n "pages.inbounds.client" }}', width: 160, scopedSlots: { customRender: 'client' } },
  992. { title: '{{ i18n "pages.inbounds.traffic" }}', width: 200, align: 'center', scopedSlots: { customRender: 'traffic' } },
  993. { title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 110, align: 'center', scopedSlots: { customRender: 'allTime' } },
  994. { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
  995. ];
  996. const innerMobileColumns = [{
  997. title: '{{ i18n "pages.inbounds.operate" }}',
  998. width: 10,
  999. align: 'center',
  1000. scopedSlots: {
  1001. customRender: 'actionMenu'
  1002. }
  1003. },
  1004. {
  1005. title: '{{ i18n "pages.inbounds.client" }}',
  1006. width: 90,
  1007. align: 'left',
  1008. scopedSlots: {
  1009. customRender: 'client'
  1010. }
  1011. },
  1012. {
  1013. title: '{{ i18n "pages.inbounds.info" }}',
  1014. width: 10,
  1015. align: 'center',
  1016. scopedSlots: {
  1017. customRender: 'info'
  1018. }
  1019. },
  1020. ];
  1021. const app = new Vue({
  1022. delimiters: ['[[', ']]'],
  1023. el: '#app',
  1024. mixins: [MediaQueryMixin],
  1025. data: {
  1026. themeSwitcher,
  1027. persianDatepicker,
  1028. loadingStates: {
  1029. fetched: false,
  1030. spinning: false
  1031. },
  1032. inbounds: [],
  1033. dbInbounds: [],
  1034. searchKey: '',
  1035. enableFilter: false,
  1036. filterBy: '',
  1037. searchedInbounds: [],
  1038. expireDiff: 0,
  1039. trafficDiff: 0,
  1040. defaultCert: '',
  1041. defaultKey: '',
  1042. clientCount: {},
  1043. onlineClients: [],
  1044. lastOnlineMap: {},
  1045. isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
  1046. refreshing: false,
  1047. refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
  1048. subSettings: {
  1049. enable: false,
  1050. subTitle: '',
  1051. subURI: '',
  1052. subJsonURI: '',
  1053. subJsonEnable: false,
  1054. },
  1055. remarkModel: '-ieo',
  1056. datepicker: 'gregorian',
  1057. tgBotEnable: false,
  1058. showAlert: false,
  1059. ipLimitEnable: false,
  1060. pageSize: 0,
  1061. },
  1062. methods: {
  1063. loading(spinning = true) {
  1064. this.loadingStates.spinning = spinning;
  1065. },
  1066. // applyClientStatsDelta updates client traffic counters and inbound totals
  1067. // in-place from a WebSocket delta payload. Avoids full-list re-fetch and
  1068. // re-render — critical at 10k+ client scale.
  1069. applyClientStatsDelta(payload) {
  1070. if (!payload || typeof payload !== 'object') return;
  1071. const inboundsById = new Map();
  1072. this.dbInbounds.forEach(ib => inboundsById.set(ib.id, ib));
  1073. const touched = new Set();
  1074. if (Array.isArray(payload.clients) && payload.clients.length > 0) {
  1075. for (const stat of payload.clients) {
  1076. const dbInbound = inboundsById.get(stat.inboundId);
  1077. if (!dbInbound || !Array.isArray(dbInbound.clientStats)) continue;
  1078. const cs = this.getClientStats(dbInbound, stat.email);
  1079. if (!cs) continue;
  1080. cs.up = stat.up;
  1081. cs.down = stat.down;
  1082. // allTime is the cumulative-historical counter shown in the
  1083. // "Общий трафик" column. The previous handler updated up/down/
  1084. // total but skipped allTime, so that column stayed frozen at
  1085. // its initial-page-load value until a manual refresh.
  1086. if (stat.allTime !== undefined) cs.allTime = stat.allTime;
  1087. if (stat.total !== undefined) cs.total = stat.total;
  1088. if (stat.expiryTime !== undefined) cs.expiryTime = stat.expiryTime;
  1089. if (stat.lastOnline !== undefined) cs.lastOnline = stat.lastOnline;
  1090. if (stat.enable !== undefined) cs.enable = stat.enable;
  1091. touched.add(stat.inboundId);
  1092. }
  1093. }
  1094. if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
  1095. for (const summary of payload.inbounds) {
  1096. const dbInbound = inboundsById.get(summary.id);
  1097. if (!dbInbound) continue;
  1098. dbInbound.up = summary.up;
  1099. dbInbound.down = summary.down;
  1100. if (summary.total !== undefined) dbInbound.total = summary.total;
  1101. if (summary.allTime !== undefined) dbInbound.allTime = summary.allTime;
  1102. if (summary.enable !== undefined) dbInbound.enable = summary.enable;
  1103. }
  1104. }
  1105. // Recompute clientCount for inbounds whose stats changed. The cached
  1106. // parsed Inbound is fetched via dbInbound.toInbound() — earlier
  1107. // versions used `this.inbounds.find(ib => ib.id === id)` which
  1108. // ALWAYS returned undefined (the Inbound class has no id field), so
  1109. // this branch silently never ran and depleted/expiring/online filters
  1110. // never refreshed from delta updates.
  1111. if (touched.size > 0) {
  1112. for (const id of touched) {
  1113. const dbInbound = inboundsById.get(id);
  1114. if (dbInbound) {
  1115. this.$set(this.clientCount, id, this.getClientCounts(dbInbound, dbInbound.toInbound()));
  1116. }
  1117. }
  1118. }
  1119. // Re-run filter/search so the displayed slice picks up updated values.
  1120. if (this.enableFilter) {
  1121. this.filterInbounds();
  1122. } else {
  1123. this.searchInbounds(this.searchKey);
  1124. }
  1125. },
  1126. async getDBInbounds() {
  1127. this.refreshing = true;
  1128. const msg = await HttpUtil.get('/panel/api/inbounds/list');
  1129. if (!msg.success) {
  1130. this.refreshing = false;
  1131. return;
  1132. }
  1133. await this.getLastOnlineMap();
  1134. await this.getOnlineUsers();
  1135. this.setInbounds(msg.obj);
  1136. setTimeout(() => {
  1137. this.refreshing = false;
  1138. }, 500);
  1139. },
  1140. async getOnlineUsers() {
  1141. const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
  1142. if (!msg.success) {
  1143. return;
  1144. }
  1145. this.onlineClients = msg.obj != null ? msg.obj : [];
  1146. },
  1147. async getLastOnlineMap() {
  1148. const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
  1149. if (!msg.success || !msg.obj) return;
  1150. this.lastOnlineMap = msg.obj || {}
  1151. },
  1152. async getDefaultSettings() {
  1153. const msg = await HttpUtil.post('/panel/setting/defaultSettings');
  1154. if (!msg.success) {
  1155. return;
  1156. }
  1157. const settings = msg.obj || {};
  1158. this.expireDiff = settings.expireDiff * 86400000;
  1159. this.trafficDiff = settings.trafficDiff * 1073741824;
  1160. this.defaultCert = settings.defaultCert;
  1161. this.defaultKey = settings.defaultKey;
  1162. this.tgBotEnable = settings.tgBotEnable;
  1163. this.subSettings = {
  1164. enable: settings.subEnable,
  1165. subTitle: settings.subTitle,
  1166. subURI: settings.subURI,
  1167. subJsonURI: settings.subJsonURI,
  1168. subJsonEnable: settings.subJsonEnable,
  1169. };
  1170. this.pageSize = settings.pageSize;
  1171. this.remarkModel = settings.remarkModel;
  1172. this.datepicker = settings.datepicker;
  1173. this.ipLimitEnable = settings.ipLimitEnable;
  1174. },
  1175. setInbounds(dbInbounds) {
  1176. this.inbounds.splice(0);
  1177. this.dbInbounds.splice(0);
  1178. // Drop every existing key — Vue.delete keeps it reactive so any
  1179. // template expression watching clientCount[id] re-renders cleanly.
  1180. for (const key of Object.keys(this.clientCount)) {
  1181. this.$delete(this.clientCount, key);
  1182. }
  1183. for (const inbound of dbInbounds) {
  1184. const dbInbound = new DBInbound(inbound);
  1185. to_inbound = dbInbound.toInbound()
  1186. this.inbounds.push(to_inbound);
  1187. this.dbInbounds.push(dbInbound);
  1188. if ([Protocols.VMESS, Protocols.VLESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA].includes(inbound
  1189. .protocol)) {
  1190. if (dbInbound.isSS && (!to_inbound.isSSMultiUser)) {
  1191. continue;
  1192. }
  1193. // Reactive add — direct assignment on the map would not trigger
  1194. // template updates in Vue 2.
  1195. this.$set(this.clientCount, inbound.id, this.getClientCounts(inbound, to_inbound));
  1196. }
  1197. }
  1198. if (!this.loadingStates.fetched) {
  1199. this.loadingStates.fetched = true
  1200. }
  1201. if (this.enableFilter) {
  1202. this.filterInbounds();
  1203. } else {
  1204. this.searchInbounds(this.searchKey);
  1205. }
  1206. },
  1207. getClientCounts(dbInbound, inbound) {
  1208. let clientCount = 0,
  1209. active = [],
  1210. deactive = [],
  1211. depleted = [],
  1212. expiring = [],
  1213. online = [],
  1214. comments = new Map();
  1215. clients = inbound.clients;
  1216. clientStats = dbInbound.clientStats
  1217. now = new Date().getTime()
  1218. if (clients) {
  1219. clientCount = clients.length;
  1220. if (dbInbound.enable) {
  1221. clients.forEach(client => {
  1222. if (client.comment) {
  1223. comments.set(client.email, client.comment)
  1224. }
  1225. if (client.enable) {
  1226. active.push(client.email);
  1227. if (this.isClientOnline(client.email)) online.push(client.email);
  1228. } else {
  1229. deactive.push(client.email);
  1230. }
  1231. });
  1232. clientStats.forEach(stats => {
  1233. const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
  1234. const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
  1235. if (expired || exhausted) {
  1236. depleted.push(stats.email);
  1237. } else {
  1238. const expiringSoon = (stats.expiryTime > 0 && (stats.expiryTime - now < this.expireDiff)) ||
  1239. (stats.total > 0 && (stats.total - (stats.up + stats.down) < this.trafficDiff));
  1240. if (expiringSoon) expiring.push(stats.email);
  1241. }
  1242. });
  1243. } else {
  1244. clients.forEach(client => {
  1245. deactive.push(client.email);
  1246. });
  1247. }
  1248. }
  1249. return {
  1250. clients: clientCount,
  1251. active: active,
  1252. deactive: deactive,
  1253. depleted: depleted,
  1254. expiring: expiring,
  1255. online: online,
  1256. comments: comments,
  1257. };
  1258. },
  1259. searchInbounds(key) {
  1260. if (ObjectUtil.isEmpty(key)) {
  1261. this.searchedInbounds = this.dbInbounds.slice();
  1262. } else {
  1263. this.searchedInbounds.splice(0, this.searchedInbounds.length);
  1264. this.dbInbounds.forEach(inbound => {
  1265. if (ObjectUtil.deepSearch(inbound, key)) {
  1266. const newInbound = new DBInbound(inbound);
  1267. const inboundSettings = JSON.parse(inbound.settings);
  1268. if (inboundSettings.hasOwnProperty('clients')) {
  1269. const searchedSettings = {
  1270. "clients": []
  1271. };
  1272. inboundSettings.clients.forEach(client => {
  1273. if (ObjectUtil.deepSearch(client, key)) {
  1274. searchedSettings.clients.push(client);
  1275. }
  1276. });
  1277. newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, searchedSettings);
  1278. }
  1279. this.searchedInbounds.push(newInbound);
  1280. }
  1281. });
  1282. }
  1283. },
  1284. filterInbounds() {
  1285. if (ObjectUtil.isEmpty(this.filterBy)) {
  1286. this.searchedInbounds = this.dbInbounds.slice();
  1287. } else {
  1288. this.searchedInbounds.splice(0, this.searchedInbounds.length);
  1289. this.dbInbounds.forEach(inbound => {
  1290. const newInbound = new DBInbound(inbound);
  1291. const inboundSettings = JSON.parse(inbound.settings);
  1292. if (this.clientCount[inbound.id] && this.clientCount[inbound.id].hasOwnProperty(this.filterBy)) {
  1293. const list = this.clientCount[inbound.id][this.filterBy];
  1294. if (list.length > 0) {
  1295. const filteredSettings = {
  1296. "clients": []
  1297. };
  1298. if (inboundSettings.clients) {
  1299. inboundSettings.clients.forEach(client => {
  1300. if (list.includes(client.email)) {
  1301. filteredSettings.clients.push(client);
  1302. }
  1303. });
  1304. }
  1305. newInbound.settings = Inbound.Settings.fromJson(inbound.protocol, filteredSettings);
  1306. this.searchedInbounds.push(newInbound);
  1307. }
  1308. }
  1309. });
  1310. }
  1311. },
  1312. toggleFilter() {
  1313. if (this.enableFilter) {
  1314. this.searchKey = '';
  1315. } else {
  1316. this.filterBy = '';
  1317. this.searchedInbounds = this.dbInbounds.slice();
  1318. }
  1319. },
  1320. generalActions(action) {
  1321. switch (action.key) {
  1322. case "import":
  1323. this.importInbound();
  1324. break;
  1325. case "export":
  1326. this.exportAllLinks();
  1327. break;
  1328. case "subs":
  1329. this.exportAllSubs();
  1330. break;
  1331. case "resetInbounds":
  1332. this.resetAllTraffic();
  1333. break;
  1334. case "resetClients":
  1335. this.resetAllClientTraffics(-1);
  1336. break;
  1337. case "delDepletedClients":
  1338. this.delDepletedClients(-1)
  1339. break;
  1340. }
  1341. },
  1342. clickAction(action, dbInbound) {
  1343. switch (action.key) {
  1344. case "qrcode":
  1345. this.showQrcode(dbInbound.id);
  1346. break;
  1347. case "showInfo":
  1348. this.showInfo(dbInbound.id);
  1349. break;
  1350. case "edit":
  1351. this.openEditInbound(dbInbound.id);
  1352. break;
  1353. case "addClient":
  1354. this.openAddClient(dbInbound.id)
  1355. break;
  1356. case "addBulkClient":
  1357. this.openAddBulkClient(dbInbound.id)
  1358. break;
  1359. case "copyClients":
  1360. copyClientsModal.show(dbInbound);
  1361. break;
  1362. case "export":
  1363. this.inboundLinks(dbInbound.id);
  1364. break;
  1365. case "subs":
  1366. this.exportSubs(dbInbound.id);
  1367. break;
  1368. case "clipboard":
  1369. this.copy(dbInbound.id);
  1370. break;
  1371. case "resetTraffic":
  1372. this.resetTraffic(dbInbound.id);
  1373. break;
  1374. case "resetClients":
  1375. this.resetAllClientTraffics(dbInbound.id);
  1376. break;
  1377. case "clone":
  1378. this.openCloneInbound(dbInbound);
  1379. break;
  1380. case "delete":
  1381. this.delInbound(dbInbound.id);
  1382. break;
  1383. case "delDepletedClients":
  1384. this.delDepletedClients(dbInbound.id)
  1385. break;
  1386. }
  1387. },
  1388. openCloneInbound(dbInbound) {
  1389. this.$confirm({
  1390. title: '{{ i18n "pages.inbounds.cloneInbound"}} \"' + dbInbound.remark + '\"',
  1391. content: '{{ i18n "pages.inbounds.cloneInboundContent"}}',
  1392. okText: '{{ i18n "pages.inbounds.cloneInboundOk"}}',
  1393. class: themeSwitcher.currentTheme,
  1394. cancelText: '{{ i18n "cancel" }}',
  1395. onOk: () => {
  1396. const baseInbound = dbInbound.toInbound();
  1397. dbInbound.up = 0;
  1398. dbInbound.down = 0;
  1399. this.cloneInbound(baseInbound, dbInbound);
  1400. },
  1401. });
  1402. },
  1403. async cloneInbound(baseInbound, dbInbound) {
  1404. const data = {
  1405. up: dbInbound.up,
  1406. down: dbInbound.down,
  1407. total: dbInbound.total,
  1408. remark: dbInbound.remark + " - Cloned",
  1409. enable: dbInbound.enable,
  1410. expiryTime: dbInbound.expiryTime,
  1411. trafficReset: dbInbound.trafficReset,
  1412. lastTrafficResetTime: dbInbound.lastTrafficResetTime,
  1413. listen: '',
  1414. port: RandomUtil.randomInteger(10000, 60000),
  1415. protocol: baseInbound.protocol,
  1416. settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
  1417. streamSettings: baseInbound.stream.toString(),
  1418. sniffing: baseInbound.sniffing.toString(),
  1419. };
  1420. await this.submit('/panel/api/inbounds/add', data, inModal);
  1421. },
  1422. openAddInbound() {
  1423. inModal.show({
  1424. title: '{{ i18n "pages.inbounds.addInbound"}}',
  1425. okText: '{{ i18n "create"}}',
  1426. cancelText: '{{ i18n "close" }}',
  1427. confirm: async (inbound, dbInbound) => {
  1428. await this.addInbound(inbound, dbInbound, inModal);
  1429. },
  1430. isEdit: false
  1431. });
  1432. },
  1433. openEditInbound(dbInboundId) {
  1434. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1435. const inbound = dbInbound.toInbound();
  1436. inModal.show({
  1437. title: '{{ i18n "pages.inbounds.modifyInbound"}}',
  1438. okText: '{{ i18n "update"}}',
  1439. cancelText: '{{ i18n "close" }}',
  1440. inbound: inbound,
  1441. dbInbound: dbInbound,
  1442. confirm: async (inbound, dbInbound) => {
  1443. await this.updateInbound(inbound, dbInbound);
  1444. },
  1445. isEdit: true
  1446. });
  1447. },
  1448. async addInbound(inbound, dbInbound) {
  1449. const data = {
  1450. up: dbInbound.up,
  1451. down: dbInbound.down,
  1452. total: dbInbound.total,
  1453. remark: dbInbound.remark,
  1454. enable: dbInbound.enable,
  1455. expiryTime: dbInbound.expiryTime,
  1456. trafficReset: dbInbound.trafficReset,
  1457. lastTrafficResetTime: dbInbound.lastTrafficResetTime,
  1458. listen: inbound.listen,
  1459. port: inbound.port,
  1460. protocol: inbound.protocol,
  1461. settings: inbound.settings.toString(),
  1462. };
  1463. if (inbound.canEnableStream()) {
  1464. data.streamSettings = inbound.stream.toString();
  1465. } else if (inbound.stream?.sockopt) {
  1466. data.streamSettings = JSON.stringify({
  1467. sockopt: inbound.stream.sockopt.toJson()
  1468. }, null, 2);
  1469. }
  1470. data.sniffing = inbound.sniffing.toString();
  1471. await this.submit('/panel/api/inbounds/add', data, inModal);
  1472. },
  1473. async updateInbound(inbound, dbInbound) {
  1474. const data = {
  1475. up: dbInbound.up,
  1476. down: dbInbound.down,
  1477. total: dbInbound.total,
  1478. remark: dbInbound.remark,
  1479. enable: dbInbound.enable,
  1480. expiryTime: dbInbound.expiryTime,
  1481. trafficReset: dbInbound.trafficReset,
  1482. lastTrafficResetTime: dbInbound.lastTrafficResetTime,
  1483. listen: inbound.listen,
  1484. port: inbound.port,
  1485. protocol: inbound.protocol,
  1486. settings: inbound.settings.toString(),
  1487. };
  1488. if (inbound.canEnableStream()) {
  1489. data.streamSettings = inbound.stream.toString();
  1490. } else if (inbound.stream?.sockopt) {
  1491. data.streamSettings = JSON.stringify({
  1492. sockopt: inbound.stream.sockopt.toJson()
  1493. }, null, 2);
  1494. }
  1495. data.sniffing = inbound.sniffing.toString();
  1496. const formData = new FormData();
  1497. Object.keys(data).forEach(key => formData.append(key, data[key]));
  1498. await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, formData, inModal);
  1499. },
  1500. openAddClient(dbInboundId) {
  1501. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1502. clientModal.show({
  1503. title: '{{ i18n "pages.client.add"}}',
  1504. okText: '{{ i18n "pages.client.submitAdd"}}',
  1505. dbInbound: dbInbound,
  1506. confirm: async (clients, dbInboundId) => {
  1507. await this.addClient(clients, dbInboundId, clientModal);
  1508. },
  1509. isEdit: false
  1510. });
  1511. },
  1512. openAddBulkClient(dbInboundId) {
  1513. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1514. clientsBulkModal.show({
  1515. title: '{{ i18n "pages.client.bulk"}} ' + dbInbound.remark,
  1516. okText: '{{ i18n "pages.client.bulk"}}',
  1517. dbInbound: dbInbound,
  1518. confirm: async (clients, dbInboundId) => {
  1519. await this.addClient(clients, dbInboundId, clientsBulkModal);
  1520. },
  1521. });
  1522. },
  1523. openEditClient(dbInboundId, client) {
  1524. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1525. if (!dbInbound) return;
  1526. clients = this.getInboundClients(dbInbound);
  1527. if (!clients || !Array.isArray(clients)) return;
  1528. index = this.findIndexOfClient(dbInbound.protocol, clients, client);
  1529. if (index < 0) return;
  1530. clientModal.show({
  1531. title: '{{ i18n "pages.client.edit"}}',
  1532. okText: '{{ i18n "pages.client.submitEdit"}}',
  1533. dbInbound: dbInbound,
  1534. index: index,
  1535. confirm: async (client, dbInboundId, clientId) => {
  1536. clientModal.loading();
  1537. await this.updateClient(client, dbInboundId, clientId);
  1538. clientModal.close();
  1539. },
  1540. isEdit: true
  1541. });
  1542. },
  1543. findIndexOfClient(protocol, clients, client) {
  1544. if (!clients || !Array.isArray(clients) || !client) {
  1545. return -1;
  1546. }
  1547. switch (protocol) {
  1548. case Protocols.TROJAN:
  1549. case Protocols.SHADOWSOCKS:
  1550. return clients.findIndex(item => item && item.password === client.password && item.email === client
  1551. .email);
  1552. default:
  1553. return clients.findIndex(item => item && item.id === client.id && item.email === client.email);
  1554. }
  1555. },
  1556. async addClient(clients, dbInboundId, modal) {
  1557. const data = {
  1558. id: dbInboundId,
  1559. settings: '{"clients": [' + clients.toString() + ']}',
  1560. };
  1561. await this.submit(`/panel/api/inbounds/addClient`, data, modal);
  1562. },
  1563. async updateClient(client, dbInboundId, clientId) {
  1564. const data = {
  1565. id: dbInboundId,
  1566. settings: '{"clients": [' + client.toString() + ']}',
  1567. };
  1568. await this.submit(`/panel/api/inbounds/updateClient/${clientId}`, data, clientModal);
  1569. },
  1570. resetTraffic(dbInboundId) {
  1571. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1572. this.$confirm({
  1573. title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' #' + dbInboundId,
  1574. content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
  1575. class: themeSwitcher.currentTheme,
  1576. okText: '{{ i18n "reset"}}',
  1577. cancelText: '{{ i18n "cancel"}}',
  1578. onOk: () => {
  1579. const inbound = dbInbound.toInbound();
  1580. dbInbound.up = 0;
  1581. dbInbound.down = 0;
  1582. this.updateInbound(inbound, dbInbound);
  1583. },
  1584. });
  1585. },
  1586. delInbound(dbInboundId) {
  1587. this.$confirm({
  1588. title: '{{ i18n "pages.inbounds.deleteInbound"}}' + ' #' + dbInboundId,
  1589. content: '{{ i18n "pages.inbounds.deleteInboundContent"}}',
  1590. class: themeSwitcher.currentTheme,
  1591. okText: '{{ i18n "delete"}}',
  1592. cancelText: '{{ i18n "cancel"}}',
  1593. onOk: () => this.submit('/panel/api/inbounds/del/' + dbInboundId),
  1594. });
  1595. },
  1596. delClient(dbInboundId, client, confirmation = true) {
  1597. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1598. clientId = this.getClientId(dbInbound.protocol, client);
  1599. if (confirmation) {
  1600. this.$confirm({
  1601. title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + client.email,
  1602. content: '{{ i18n "pages.inbounds.deleteClientContent"}}',
  1603. class: themeSwitcher.currentTheme,
  1604. okText: '{{ i18n "delete"}}',
  1605. cancelText: '{{ i18n "cancel"}}',
  1606. onOk: () => this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`),
  1607. });
  1608. } else {
  1609. this.submit(`/panel/api/inbounds/${dbInboundId}/delClient/${clientId}`);
  1610. }
  1611. },
  1612. getSubGroupClients(dbInbounds, currentClient) {
  1613. const response = {
  1614. inbounds: [],
  1615. clients: [],
  1616. editIds: []
  1617. }
  1618. if (dbInbounds && dbInbounds.length > 0 && currentClient) {
  1619. dbInbounds.forEach((dbInboundItem) => {
  1620. const dbInbound = new DBInbound(dbInboundItem);
  1621. if (dbInbound) {
  1622. const inbound = dbInbound.toInbound();
  1623. if (inbound) {
  1624. const clients = inbound.clients;
  1625. if (clients.length > 0) {
  1626. clients.forEach((client) => {
  1627. if (client['subId'] === currentClient['subId']) {
  1628. client['inboundId'] = dbInboundItem.id
  1629. client['clientId'] = this.getClientId(dbInbound.protocol, client)
  1630. response.inbounds.push(dbInboundItem.id)
  1631. response.clients.push(client)
  1632. response.editIds.push(client['clientId'])
  1633. }
  1634. })
  1635. }
  1636. }
  1637. }
  1638. })
  1639. }
  1640. return response;
  1641. },
  1642. getClientId(protocol, client) {
  1643. switch (protocol) {
  1644. case Protocols.TROJAN:
  1645. return client.password;
  1646. case Protocols.SHADOWSOCKS:
  1647. return client.email;
  1648. case Protocols.HYSTERIA:
  1649. return client.auth;
  1650. default:
  1651. return client.id;
  1652. }
  1653. },
  1654. checkFallback(dbInbound) {
  1655. newDbInbound = new DBInbound(dbInbound);
  1656. if (dbInbound.listen.startsWith("@")) {
  1657. rootInbound = this.inbounds.find((i) =>
  1658. i.isTcp && ['trojan', 'vless'].includes(i.protocol) &&
  1659. i.settings.fallbacks.find(f => f.dest === dbInbound.listen)
  1660. );
  1661. if (rootInbound) {
  1662. newDbInbound.listen = rootInbound.listen;
  1663. newDbInbound.port = rootInbound.port;
  1664. newInbound = newDbInbound.toInbound();
  1665. newInbound.stream.security = rootInbound.stream.security;
  1666. newInbound.stream.tls = rootInbound.stream.tls;
  1667. newInbound.stream.externalProxy = rootInbound.stream.externalProxy;
  1668. newDbInbound.streamSettings = newInbound.stream.toString();
  1669. }
  1670. }
  1671. return newDbInbound;
  1672. },
  1673. showQrcode(dbInboundId, client) {
  1674. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1675. newDbInbound = this.checkFallback(dbInbound);
  1676. qrModal.show('{{ i18n "qrCode"}}', newDbInbound, client);
  1677. },
  1678. showInfo(dbInboundId, client) {
  1679. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1680. if (!dbInbound) return;
  1681. index = 0;
  1682. if (dbInbound.isMultiUser()) {
  1683. inbound = dbInbound.toInbound();
  1684. clients = inbound && inbound.clients ? inbound.clients : null;
  1685. if (clients && Array.isArray(clients)) {
  1686. index = this.findIndexOfClient(dbInbound.protocol, clients, client);
  1687. if (index < 0) index = 0;
  1688. }
  1689. }
  1690. newDbInbound = this.checkFallback(dbInbound);
  1691. infoModal.show(newDbInbound, index);
  1692. },
  1693. // switchEnable toggles inbound.enable through a dedicated lightweight
  1694. // endpoint. The previous implementation re-submitted the entire
  1695. // inbound settings JSON (every client) just to flip a boolean — on a
  1696. // 7000+ client inbound that meant a multi-MB request, an O(N) traffic
  1697. // diff and a full xray-config rebuild for every click of the switch.
  1698. async switchEnable(dbInboundId, state) {
  1699. const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1700. if (!dbInbound) return;
  1701. const previous = dbInbound.enable;
  1702. dbInbound.enable = state; // optimistic: UI reflects the click immediately
  1703. const formData = new FormData();
  1704. formData.append('enable', String(state));
  1705. try {
  1706. const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInboundId}`, formData);
  1707. if (!msg || !msg.success) {
  1708. dbInbound.enable = previous;
  1709. }
  1710. } catch (e) {
  1711. dbInbound.enable = previous;
  1712. }
  1713. },
  1714. async switchEnableClient(dbInboundId, client, state) {
  1715. this.loading();
  1716. try {
  1717. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1718. if (!dbInbound) return;
  1719. inbound = dbInbound.toInbound();
  1720. clients = inbound && inbound.clients ? inbound.clients : null;
  1721. if (!clients || !Array.isArray(clients)) return;
  1722. index = this.findIndexOfClient(dbInbound.protocol, clients, client);
  1723. if (index < 0 || !clients[index]) return;
  1724. clients[index].enable = typeof state === 'boolean' ? state : !!client.enable;
  1725. clientId = this.getClientId(dbInbound.protocol, clients[index]);
  1726. await this.updateClient(clients[index], dbInboundId, clientId);
  1727. } finally {
  1728. this.loading(false);
  1729. }
  1730. },
  1731. async submit(url, data, modal) {
  1732. const msg = await HttpUtil.postWithModal(url, data, modal);
  1733. if (msg.success) {
  1734. await this.getDBInbounds();
  1735. }
  1736. },
  1737. getInboundClients(dbInbound) {
  1738. if (!dbInbound) return null;
  1739. const inbound = dbInbound.toInbound();
  1740. return inbound && inbound.clients ? inbound.clients : null;
  1741. },
  1742. resetClientTraffic(client, dbInboundId, confirmation = true) {
  1743. if (confirmation) {
  1744. this.$confirm({
  1745. title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
  1746. content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
  1747. class: themeSwitcher.currentTheme,
  1748. okText: '{{ i18n "reset"}}',
  1749. cancelText: '{{ i18n "cancel"}}',
  1750. onOk: () => this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client
  1751. .email),
  1752. })
  1753. } else {
  1754. this.submit('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + client.email);
  1755. }
  1756. },
  1757. resetAllTraffic() {
  1758. this.$confirm({
  1759. title: '{{ i18n "pages.inbounds.resetAllTrafficTitle"}}',
  1760. content: '{{ i18n "pages.inbounds.resetAllTrafficContent"}}',
  1761. class: themeSwitcher.currentTheme,
  1762. okText: '{{ i18n "reset"}}',
  1763. cancelText: '{{ i18n "cancel"}}',
  1764. onOk: () => this.submit('/panel/api/inbounds/resetAllTraffics'),
  1765. });
  1766. },
  1767. resetAllClientTraffics(dbInboundId) {
  1768. this.$confirm({
  1769. title: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficTitle"}}' :
  1770. '{{ i18n "pages.inbounds.resetAllClientTrafficTitle"}}',
  1771. content: dbInboundId > 0 ? '{{ i18n "pages.inbounds.resetInboundClientTrafficContent"}}' :
  1772. '{{ i18n "pages.inbounds.resetAllClientTrafficContent"}}',
  1773. class: themeSwitcher.currentTheme,
  1774. okText: '{{ i18n "reset"}}',
  1775. cancelText: '{{ i18n "cancel"}}',
  1776. onOk: () => this.submit('/panel/api/inbounds/resetAllClientTraffics/' + dbInboundId),
  1777. })
  1778. },
  1779. delDepletedClients(dbInboundId) {
  1780. this.$confirm({
  1781. title: '{{ i18n "pages.inbounds.delDepletedClientsTitle"}}',
  1782. content: '{{ i18n "pages.inbounds.delDepletedClientsContent"}}',
  1783. class: themeSwitcher.currentTheme,
  1784. okText: '{{ i18n "delete"}}',
  1785. cancelText: '{{ i18n "cancel"}}',
  1786. onOk: () => this.submit('/panel/api/inbounds/delDepletedClients/' + dbInboundId),
  1787. })
  1788. },
  1789. isExpiry(dbInbound, index) {
  1790. return dbInbound.toInbound().isExpiry(index);
  1791. },
  1792. // getClientStats returns the cached email→clientStat lookup for an
  1793. // inbound, building it lazily. The cache is invalidated when the
  1794. // underlying clientStats array reference changes (full re-fetch),
  1795. // so delta updates and post-refetch lookups never see stale entries.
  1796. // This is the single source of truth — applyClientStatsDelta uses it too.
  1797. getClientStats(dbInbound, email) {
  1798. if (!dbInbound || !Array.isArray(dbInbound.clientStats)) return null;
  1799. if (!dbInbound._clientStatsMap || dbInbound._clientStatsMapSrc !== dbInbound.clientStats) {
  1800. const map = new Map();
  1801. for (const cs of dbInbound.clientStats) map.set(cs.email, cs);
  1802. dbInbound._clientStatsMap = map;
  1803. dbInbound._clientStatsMapSrc = dbInbound.clientStats;
  1804. }
  1805. return dbInbound._clientStatsMap.get(email);
  1806. },
  1807. getUpStats(dbInbound, email) {
  1808. if (!email || email.length == 0) return 0;
  1809. let clientStats = this.getClientStats(dbInbound, email);
  1810. return clientStats ? clientStats.up : 0;
  1811. },
  1812. getDownStats(dbInbound, email) {
  1813. if (!email || email.length == 0) return 0;
  1814. let clientStats = this.getClientStats(dbInbound, email);
  1815. return clientStats ? clientStats.down : 0;
  1816. },
  1817. getSumStats(dbInbound, email) {
  1818. if (!email || email.length == 0) return 0;
  1819. let clientStats = this.getClientStats(dbInbound, email);
  1820. return clientStats ? clientStats.up + clientStats.down : 0;
  1821. },
  1822. getAllTimeClient(dbInbound, email) {
  1823. if (!email || email.length == 0) return 0;
  1824. const clientStats = this.getClientStats(dbInbound, email);
  1825. if (!clientStats) return 0;
  1826. // allTime represents cumulative historical usage and must never
  1827. // appear smaller than the currently-tracked counters. If a stale
  1828. // row drifts below up+down (manual edits, partial migrations) we
  1829. // surface the live total instead of the misleading historical one.
  1830. const current = (clientStats.up || 0) + (clientStats.down || 0);
  1831. const allTime = clientStats.allTime || 0;
  1832. return allTime > current ? allTime : current;
  1833. },
  1834. getRemStats(dbInbound, email) {
  1835. if (!email || email.length == 0) return 0;
  1836. let clientStats = this.getClientStats(dbInbound, email);
  1837. if (!clientStats) return 0;
  1838. let remained = clientStats.total - (clientStats.up + clientStats.down);
  1839. return remained > 0 ? remained : 0;
  1840. },
  1841. clientStatsColor(dbInbound, email) {
  1842. if (!email || email.length == 0) return ColorUtils.clientUsageColor();
  1843. let clientStats = this.getClientStats(dbInbound, email);
  1844. return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
  1845. },
  1846. statsProgress(dbInbound, email) {
  1847. if (!email || email.length == 0) return 100;
  1848. let clientStats = this.getClientStats(dbInbound, email);
  1849. if (!clientStats) return 0;
  1850. if (clientStats.total == 0) return 100;
  1851. return 100 * (clientStats.down + clientStats.up) / clientStats.total;
  1852. },
  1853. expireProgress(expTime, reset) {
  1854. now = new Date().getTime();
  1855. remainedSeconds = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
  1856. resetSeconds = reset * 86400;
  1857. if (remainedSeconds >= resetSeconds) return 0;
  1858. return 100 * (1 - (remainedSeconds / resetSeconds));
  1859. },
  1860. statsExpColor(dbInbound, email) {
  1861. if (!email || email.length == 0) return '#7a316f';
  1862. let clientStats = this.getClientStats(dbInbound, email);
  1863. if (!clientStats) return '#7a316f';
  1864. let statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats
  1865. .total);
  1866. let expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
  1867. switch (true) {
  1868. case statsColor == "red" || expColor == "red":
  1869. return "#cf3c3c"; // Red
  1870. case statsColor == "orange" || expColor == "orange":
  1871. return "#f37b24"; // Orange
  1872. case statsColor == "green" || expColor == "green":
  1873. return "#008771"; // Green
  1874. default:
  1875. return "#7a316f"; // purple
  1876. }
  1877. },
  1878. isClientEnabled(dbInbound, email) {
  1879. let clientStats = dbInbound ? this.getClientStats(dbInbound, email) : null;
  1880. return clientStats ? clientStats['enable'] : true;
  1881. },
  1882. isClientDepleted(dbInbound, email) {
  1883. if (!email || !dbInbound) return false;
  1884. const stats = this.getClientStats(dbInbound, email);
  1885. if (!stats) return false;
  1886. const total = stats.total ?? 0;
  1887. const used = (stats.up ?? 0) + (stats.down ?? 0);
  1888. const hasTotal = total > 0;
  1889. const exhausted = hasTotal && used >= total;
  1890. const expiryTime = stats.expiryTime ?? 0;
  1891. const hasExpiry = expiryTime > 0;
  1892. const now = Date.now();
  1893. const expired = hasExpiry && expiryTime <= now;
  1894. return expired || exhausted;
  1895. },
  1896. isClientOnline(email) {
  1897. return this.onlineClients.includes(email);
  1898. },
  1899. getLastOnline(email) {
  1900. return this.lastOnlineMap[email] || null
  1901. },
  1902. formatLastOnline(email) {
  1903. const ts = this.getLastOnline(email)
  1904. if (!ts) return '-'
  1905. // Check if IntlUtil is available (may not be loaded yet)
  1906. if (typeof IntlUtil !== 'undefined' && IntlUtil.formatDate) {
  1907. return IntlUtil.formatDate(ts)
  1908. }
  1909. // Fallback to simple date formatting if IntlUtil is not available
  1910. return new Date(ts).toLocaleString()
  1911. },
  1912. isRemovable(dbInboundId) {
  1913. return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1;
  1914. },
  1915. inboundLinks(dbInboundId) {
  1916. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1917. newDbInbound = this.checkFallback(dbInbound);
  1918. txtModal.show('{{ i18n "pages.inbounds.export"}}', newDbInbound.genInboundLinks(this.remarkModel),
  1919. newDbInbound.remark);
  1920. },
  1921. exportSubs(dbInboundId) {
  1922. const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1923. const clients = this.getInboundClients(dbInbound);
  1924. let subLinks = []
  1925. if (clients != null) {
  1926. clients.forEach(c => {
  1927. if (c.subId && c.subId.length > 0) {
  1928. subLinks.push(this.subSettings.subURI + c.subId)
  1929. }
  1930. })
  1931. }
  1932. txtModal.show(
  1933. '{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
  1934. [...new Set(subLinks)].join('\n'),
  1935. dbInbound.remark + "-Subs");
  1936. },
  1937. importInbound() {
  1938. promptModal.open({
  1939. title: '{{ i18n "pages.inbounds.importInbound" }}',
  1940. type: 'textarea',
  1941. value: '',
  1942. okText: '{{ i18n "pages.inbounds.import" }}',
  1943. confirm: async (dbInboundText) => {
  1944. await this.submit('/panel/api/inbounds/import', {
  1945. data: dbInboundText
  1946. }, promptModal);
  1947. },
  1948. });
  1949. },
  1950. exportAllSubs() {
  1951. let subLinks = []
  1952. for (const dbInbound of this.dbInbounds) {
  1953. const clients = this.getInboundClients(dbInbound);
  1954. if (clients != null) {
  1955. clients.forEach(c => {
  1956. if (c.subId && c.subId.length > 0) {
  1957. subLinks.push(this.subSettings.subURI + c.subId)
  1958. }
  1959. })
  1960. }
  1961. }
  1962. txtModal.show(
  1963. '{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}',
  1964. [...new Set(subLinks)].join('\r\n'),
  1965. 'All-Inbounds-Subs');
  1966. },
  1967. exportAllLinks() {
  1968. let copyText = [];
  1969. for (const dbInbound of this.dbInbounds) {
  1970. copyText.push(dbInbound.genInboundLinks(this.remarkModel));
  1971. }
  1972. txtModal.show('{{ i18n "pages.inbounds.export"}}', copyText.join('\r\n'), 'All-Inbounds');
  1973. },
  1974. copy(dbInboundId) {
  1975. dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
  1976. txtModal.show('{{ i18n "pages.inbounds.inboundData" }}', JSON.stringify(dbInbound, null, 2));
  1977. },
  1978. async startDataRefreshLoop() {
  1979. while (this.isRefreshEnabled) {
  1980. try {
  1981. await this.getDBInbounds();
  1982. } catch (e) {
  1983. console.error(e);
  1984. }
  1985. await PromiseUtil.sleep(this.refreshInterval);
  1986. }
  1987. },
  1988. toggleRefresh() {
  1989. localStorage.setItem("isRefreshEnabled", this.isRefreshEnabled);
  1990. if (this.isRefreshEnabled) {
  1991. this.startDataRefreshLoop();
  1992. }
  1993. },
  1994. changeRefreshInterval() {
  1995. localStorage.setItem("refreshInterval", this.refreshInterval);
  1996. },
  1997. async manualRefresh() {
  1998. if (!this.refreshing) {
  1999. this.loadingStates.spinning = true;
  2000. await this.getDBInbounds();
  2001. this.loadingStates.spinning = false;
  2002. }
  2003. },
  2004. pagination(obj) {
  2005. if (this.pageSize > 0 && obj.length > this.pageSize) {
  2006. // Set page options based on object size
  2007. let sizeOptions = [this.pageSize.toString()];
  2008. const increments = [2, 5, 10, 20];
  2009. for (const m of increments) {
  2010. const val = this.pageSize * m;
  2011. if (val < obj.length && val <= 1000) {
  2012. sizeOptions.push(val.toString());
  2013. }
  2014. }
  2015. // Add option to see all in one page
  2016. if (!sizeOptions.includes(obj.length.toString())) {
  2017. sizeOptions.push(obj.length.toString());
  2018. }
  2019. p = {
  2020. showSizeChanger: true,
  2021. size: 'small',
  2022. position: 'bottom',
  2023. pageSize: this.pageSize,
  2024. pageSizeOptions: sizeOptions
  2025. };
  2026. return p;
  2027. }
  2028. return false
  2029. }
  2030. },
  2031. watch: {
  2032. searchKey: Utils.debounce(function(newVal) {
  2033. this.searchInbounds(newVal);
  2034. }, 500)
  2035. },
  2036. mounted() {
  2037. if (window.location.protocol !== "https:") {
  2038. this.showAlert = true;
  2039. }
  2040. this.loading();
  2041. this.getDefaultSettings();
  2042. // Bootstrap from REST first, then attach WebSocket subscriptions.
  2043. // Doing this in order eliminates a race where an early `inbounds` push
  2044. // fires getClientCounts() before this.onlineClients is populated,
  2045. // leaving online[] empty for every inbound and breaking the filter.
  2046. this.getDBInbounds().then(() => {
  2047. this.loading(false);
  2048. if (!window.wsClient) {
  2049. // Fallback to polling if WebSocket is not available
  2050. if (this.isRefreshEnabled) this.startDataRefreshLoop();
  2051. return;
  2052. }
  2053. window.wsClient.connect();
  2054. // Listen for inbounds updates
  2055. window.wsClient.on('inbounds', (payload) => {
  2056. if (payload && Array.isArray(payload)) {
  2057. // Use setInbounds to properly convert to DBInbound objects with methods
  2058. this.setInbounds(payload);
  2059. }
  2060. });
  2061. // Listen for invalidate signals — last-resort safety only.
  2062. // Under normal operation the server pushes 'client_stats' deltas
  2063. // instead, so this fires only when an admin mutation produces an
  2064. // oversized full-list payload.
  2065. let invalidateTimer = null;
  2066. window.wsClient.on('invalidate', (payload) => {
  2067. if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) {
  2068. if (invalidateTimer) clearTimeout(invalidateTimer);
  2069. invalidateTimer = setTimeout(() => {
  2070. invalidateTimer = null;
  2071. this.getDBInbounds();
  2072. }, 1000);
  2073. }
  2074. });
  2075. // Real-time delta updates: per-client absolute counters + inbound
  2076. // totals applied in-place. Replaces the periodic full-list refresh
  2077. // and scales to 10k+ clients without REST fallback.
  2078. window.wsClient.on('client_stats', (payload) => {
  2079. if (!payload) return;
  2080. this.applyClientStatsDelta(payload);
  2081. });
  2082. // Listen for traffic updates.
  2083. // Note: clientTraffics contains DELTA values (incremental since last
  2084. // tick), not absolute totals. Absolute counters are updated through
  2085. // the 'client_stats' event in applyClientStatsDelta.
  2086. window.wsClient.on('traffic', (payload) => {
  2087. if (!payload || typeof payload !== 'object') return;
  2088. // Normalize onlineClients: server marshals a nil []string slice as
  2089. // JSON null when nobody is online. Treat null/undefined/missing as
  2090. // an empty array so the "everyone went offline" transition still
  2091. // updates the UI — without this fix, the last set of online users
  2092. // stayed visible (and the online filter kept showing them) until
  2093. // someone came back online.
  2094. const hasOnlinePayload =
  2095. 'onlineClients' in payload &&
  2096. (Array.isArray(payload.onlineClients) || payload.onlineClients == null);
  2097. if (hasOnlinePayload) {
  2098. const nextOnlineClients = Array.isArray(payload.onlineClients)
  2099. ? payload.onlineClients
  2100. : [];
  2101. // Detect change in either direction: length differs OR sets differ.
  2102. let onlineChanged = this.onlineClients.length !== nextOnlineClients.length;
  2103. if (!onlineChanged) {
  2104. const prevSet = new Set(this.onlineClients);
  2105. for (const email of nextOnlineClients) {
  2106. if (!prevSet.has(email)) {
  2107. onlineChanged = true;
  2108. break;
  2109. }
  2110. }
  2111. }
  2112. this.onlineClients = nextOnlineClients;
  2113. if (onlineChanged) {
  2114. // Recompute clientCount for every inbound whose stats can host
  2115. // online clients. `dbInbound.toInbound()` returns the cached
  2116. // parsed Inbound (with the .clients array) — using it directly
  2117. // avoids a brittle `this.inbounds.find(ib => ib.id === ...)`
  2118. // lookup that ALWAYS failed because the Inbound class has no
  2119. // `id` field. That silent failure was the real cause of the
  2120. // online filter showing an empty list while a client was
  2121. // clearly online elsewhere on the page.
  2122. this.dbInbounds.forEach(dbInbound => {
  2123. const inbound = dbInbound.toInbound();
  2124. this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
  2125. });
  2126. // Re-run filter/search so the UI reflects the new state — both
  2127. // when clients come online and when they go offline.
  2128. if (this.enableFilter) {
  2129. this.filterInbounds();
  2130. } else {
  2131. this.searchInbounds(this.searchKey);
  2132. }
  2133. }
  2134. }
  2135. // Update last-online map. Server sends the full map (not delta) so
  2136. // we can replace entirely without growing unbounded from deleted clients.
  2137. if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
  2138. this.lastOnlineMap = payload.lastOnlineMap;
  2139. }
  2140. });
  2141. // Fallback to polling if WebSocket fails
  2142. window.wsClient.on('error', () => {
  2143. console.warn('WebSocket connection failed, falling back to polling');
  2144. if (this.isRefreshEnabled) {
  2145. this.startDataRefreshLoop();
  2146. }
  2147. });
  2148. window.wsClient.on('disconnected', () => {
  2149. if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
  2150. console.warn('WebSocket reconnection failed, falling back to polling');
  2151. if (this.isRefreshEnabled) {
  2152. this.startDataRefreshLoop();
  2153. }
  2154. }
  2155. });
  2156. });
  2157. },
  2158. computed: {
  2159. total() {
  2160. let down = 0,
  2161. up = 0,
  2162. allTime = 0;
  2163. let clients = 0,
  2164. deactive = [],
  2165. depleted = [],
  2166. expiring = [];
  2167. this.dbInbounds.forEach(dbInbound => {
  2168. down += dbInbound.down;
  2169. up += dbInbound.up;
  2170. allTime += (dbInbound.allTime || (dbInbound.up + dbInbound.down));
  2171. if (this.clientCount[dbInbound.id]) {
  2172. clients += this.clientCount[dbInbound.id].clients;
  2173. deactive = deactive.concat(this.clientCount[dbInbound.id].deactive);
  2174. depleted = depleted.concat(this.clientCount[dbInbound.id].depleted);
  2175. expiring = expiring.concat(this.clientCount[dbInbound.id].expiring);
  2176. }
  2177. });
  2178. return {
  2179. down: down,
  2180. up: up,
  2181. allTime: allTime,
  2182. clients: clients,
  2183. deactive: deactive,
  2184. depleted: depleted,
  2185. expiring: expiring,
  2186. };
  2187. }
  2188. },
  2189. });
  2190. </script>
  2191. <style>
  2192. #content-layout>.ant-layout-content>.ant-spin-nested-loading>div>.ant-spin {
  2193. position: fixed !important;
  2194. top: 50vh !important;
  2195. left: calc(50vw + 100px) !important;
  2196. transform: translate(-50%, -50%);
  2197. z-index: 99999 !important;
  2198. }
  2199. @media (max-width: 768px) {
  2200. #content-layout>.ant-layout-content>.ant-spin-nested-loading>div>.ant-spin {
  2201. left: 50vw !important;
  2202. }
  2203. /* Keep filter choices in a single horizontal line on phones. */
  2204. .inbounds-page .mobile-filter-group {
  2205. display: flex;
  2206. flex-wrap: nowrap;
  2207. max-width: 100%;
  2208. overflow-x: auto;
  2209. overflow-y: hidden;
  2210. padding-bottom: 2px;
  2211. -webkit-overflow-scrolling: touch;
  2212. scrollbar-width: thin;
  2213. }
  2214. .inbounds-page .mobile-filter-group .ant-radio-button-wrapper {
  2215. flex: 0 0 auto;
  2216. white-space: nowrap;
  2217. }
  2218. /* Prevent mobile row content from splitting across multiple lines. */
  2219. .inbounds-page .ant-table-tbody > tr > td {
  2220. white-space: nowrap;
  2221. }
  2222. .inbounds-page .ant-table-tbody > tr > td:nth-child(3) {
  2223. overflow: hidden;
  2224. text-overflow: ellipsis;
  2225. }
  2226. }
  2227. /* Protocol cell — wrap tags into a flex grid with consistent gap so
  2228. vless/xhttp/Reality line up cleanly instead of stacking awkwardly. */
  2229. .inbounds-page .protocol-tags {
  2230. display: inline-flex;
  2231. flex-wrap: wrap;
  2232. gap: 4px;
  2233. align-items: center;
  2234. max-width: 100%;
  2235. }
  2236. .inbounds-page .protocol-tags .ant-tag {
  2237. margin: 0;
  2238. line-height: 20px;
  2239. }
  2240. /* Traffic / expiry cell — flex layout:
  2241. - Side text (.tr-table-rt / .tr-table-lt) sizes to content.
  2242. - Progress bar (.tr-table-bar) takes whatever's left and is allowed to
  2243. shrink (min-width: 0) so the cell never visually overflows its column,
  2244. no matter how long the surrounding values are ("999.99 GB", "365d").
  2245. A flex <div> replaces the previous <table>/<tr>/<td> hack — table layout
  2246. ignored width: 100% on the row, so the row grew to its content width and
  2247. pushed past the a-table column boundary. */
  2248. .inbounds-page .tr-table-box {
  2249. display: flex;
  2250. gap: 8px;
  2251. align-items: center;
  2252. width: 100%;
  2253. min-width: 0;
  2254. box-sizing: border-box;
  2255. padding: 2px 10px;
  2256. border-radius: 100px;
  2257. background: rgba(255, 255, 255, 0.04);
  2258. }
  2259. /* Fixed widths so the bar starts/ends at the same X position across all
  2260. rows — without this, "126.45 MB" and "0 B" pushed the bar to different
  2261. spots, which read as misalignment in the column. */
  2262. .inbounds-page .tr-table-rt,
  2263. .inbounds-page .tr-table-lt {
  2264. flex: 0 0 auto;
  2265. white-space: nowrap;
  2266. font-variant-numeric: tabular-nums;
  2267. overflow: hidden;
  2268. text-overflow: ellipsis;
  2269. }
  2270. .inbounds-page .tr-table-rt {
  2271. text-align: end;
  2272. flex-basis: 70px;
  2273. min-width: 70px;
  2274. }
  2275. .inbounds-page .tr-table-lt {
  2276. text-align: start;
  2277. flex-basis: 28px;
  2278. min-width: 28px;
  2279. }
  2280. .inbounds-page .tr-table-bar {
  2281. flex: 1 1 0;
  2282. min-width: 0;
  2283. overflow: hidden;
  2284. display: block;
  2285. }
  2286. /* Make the progress widget fill its flex cell, and align the inner fill
  2287. pill with the outer track pill (the "two pills" drift was caused by
  2288. box-sizing: content-box plus a 1px border on .ant-progress-bg). */
  2289. .inbounds-page .tr-table-bar .ant-progress,
  2290. .inbounds-page .tr-table-bar .ant-progress-outer,
  2291. .inbounds-page .tr-table-bar .ant-progress-inner {
  2292. display: block;
  2293. width: 100%;
  2294. margin: 0;
  2295. padding: 0;
  2296. }
  2297. .inbounds-page .infinite-bar .ant-progress-inner,
  2298. .inbounds-page .tr-table-bar .ant-progress-inner {
  2299. box-sizing: border-box;
  2300. border-radius: 100px;
  2301. overflow: hidden;
  2302. }
  2303. .inbounds-page .infinite-bar .ant-progress-inner .ant-progress-bg,
  2304. .inbounds-page .tr-table-bar .ant-progress-inner .ant-progress-bg {
  2305. box-sizing: border-box;
  2306. border: 0 !important;
  2307. }
  2308. </style>
  2309. {{ template "page/body_end" .}}