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