inbounds.html 103 KB

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