inbounds.html 95 KB

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