mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
refactor(api): move every client-shaped endpoint off /inbounds onto /clients
After the multi-inbound client migration, client state belongs to the client API surface, not the inbound one. Twelve routes that were crammed under /panel/api/inbounds/* now live where they belong, under /panel/api/clients/*. Moved (route, handler, doc): POST /clientIps/:email POST /clearClientIps/:email POST /onlines POST /lastOnline POST /updateClientTraffic/:email POST /resetAllClientTraffics/:id POST /delDepletedClients/:id POST /:id/resetClientTraffic/:email GET /getClientTraffics/:email GET /getClientTrafficsById/:id GET /getSubLinks/:subId GET /getClientLinks/:id/:email Their /clients/* counterparts are: POST /clients/clientIps/:email POST /clients/clearClientIps/:email POST /clients/onlines POST /clients/lastOnline POST /clients/updateTraffic/:email POST /clients/resetTraffic/:email (email-only, fans out) GET /clients/traffic/:email GET /clients/traffic/byId/:id GET /clients/subLinks/:subId GET /clients/links/:id/:email per-inbound resetAllClientTraffics and delDepletedClients are dropped entirely — the Clients page already exposes global Reset All Traffic and Delete depleted actions, and per-inbound resets are meaningless once a client can be attached to many inbounds. ClientService.ResetTrafficByEmail is the new email-only reset path: it looks up every inbound the client is attached to and pushes the counter reset + Xray re-add through inboundService.ResetClientTraffic for each one, so depleted users come back online instantly. Frontend callers (ClientsPage, useClients, ClientQrModal, ClientInfoModal, InboundInfoModal, InboundsPage, useInbounds) all switched to the new paths. The Inbounds page drops its per-inbound "Reset client traffic" and "Delete depleted clients" dropdown items — users do those at the client level now. api-docs is rebuilt to match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c84799ea2b
commit
0fe48124c9
11 changed files with 268 additions and 535 deletions
|
|
@ -88,24 +88,6 @@ export const sections = [
|
|||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientTraffics/:email',
|
||||
summary: 'Traffic counters for a client identified by email.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientTrafficsById/:id',
|
||||
summary: 'Traffic counters for a client identified by its UUID/password.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
|
|
@ -140,59 +122,6 @@ export const sections = [
|
|||
],
|
||||
body: '{\n "enable": false\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/clientIps/:email',
|
||||
summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/clearClientIps/:email',
|
||||
summary: 'Reset the recorded IP list for a client.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/addClient',
|
||||
summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
|
||||
body:
|
||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/copyClients',
|
||||
summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
|
||||
{ name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
|
||||
{ name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
|
||||
{ name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/delClient/:clientId',
|
||||
summary: 'Delete a client by its UUID/password from a specific inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/updateClient/:clientId',
|
||||
summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
|
||||
params: [
|
||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
body:
|
||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/resetTraffic',
|
||||
|
|
@ -201,36 +130,11 @@ export const sections = [
|
|||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
|
||||
summary: 'Zero out upload + download counters for one client.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllTraffics',
|
||||
summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllClientTraffics/:id',
|
||||
summary: 'Reset traffic for every client in one inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/delDepletedClients/:id',
|
||||
summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/import',
|
||||
|
|
@ -239,59 +143,6 @@ export const sections = [
|
|||
{ name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/onlines',
|
||||
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
|
||||
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/lastOnline',
|
||||
summary: 'Map of client email → last-seen unix timestamp.',
|
||||
response: '{\n "success": true,\n "obj": [\n { "email": "user1", "lastOnline": 1700000000 },\n { "email": "user2", "lastOnline": 1699999000 }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getSubLinks/:subId',
|
||||
summary:
|
||||
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
|
||||
params: [
|
||||
{ name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientLinks/:id/:email',
|
||||
summary:
|
||||
"Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/updateClientTraffic/:email',
|
||||
summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/delClientByEmail/:email',
|
||||
summary: 'Delete a client identified by email rather than UUID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/:id/fallbackChildren',
|
||||
|
|
@ -603,6 +454,92 @@ export const sections = [
|
|||
summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
|
||||
response: '{\n "success": true,\n "obj": {\n "deleted": 0\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/resetTraffic/:email',
|
||||
summary: 'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/updateTraffic/:email',
|
||||
summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/clientIps/:email',
|
||||
summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/clearClientIps/:email',
|
||||
summary: 'Reset the recorded IP list for a client.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/onlines',
|
||||
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
|
||||
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/lastOnline',
|
||||
summary: 'Map of client email → last-seen unix timestamp.',
|
||||
response: '{\n "success": true,\n "obj": {\n "user1": 1700000000,\n "user2": 1699999000\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/traffic/:email',
|
||||
summary: 'Traffic counters for a client identified by email.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/traffic/byId/:id',
|
||||
summary: 'Traffic counters for a client identified by its UUID/password.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": [\n {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/subLinks/:subId',
|
||||
summary:
|
||||
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
|
||||
params: [
|
||||
{ name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/links/:id/:email',
|
||||
summary:
|
||||
"Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ async function loadLinks() {
|
|||
linksLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get(
|
||||
`/panel/api/inbounds/getSubLinks/${encodeURIComponent(props.client.subId)}`,
|
||||
`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`,
|
||||
);
|
||||
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ watch(() => props.open, async (next) => {
|
|||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get(`/panel/api/inbounds/getSubLinks/${encodeURIComponent(props.client.subId)}`);
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`);
|
||||
links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function useClients() {
|
|||
}
|
||||
|
||||
async function refreshOnlines() {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
|
||||
const msg = await HttpUtil.post('/panel/api/clients/onlines');
|
||||
if (msg?.success) {
|
||||
onlines.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
|
|
@ -85,9 +85,8 @@ export function useClients() {
|
|||
}
|
||||
|
||||
async function resetTraffic(client) {
|
||||
const ibIds = Array.isArray(client?.inboundIds) ? client.inboundIds : [];
|
||||
if (!client?.email || ibIds.length === 0) return null;
|
||||
const url = `/panel/api/inbounds/${ibIds[0]}/resetClientTraffic/${encodeURIComponent(client.email)}`;
|
||||
if (!client?.email) return null;
|
||||
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
|
||||
const msg = await HttpUtil.post(url);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ async function loadClientIps() {
|
|||
if (!clientStats.value?.email) return;
|
||||
refreshing.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${clientStats.value.email}`);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/clientIps/${clientStats.value.email}`);
|
||||
if (!msg?.success) {
|
||||
clientIpsText.value = msg?.obj || 'No IP record';
|
||||
clientIpsArray.value = [];
|
||||
|
|
@ -164,7 +164,7 @@ async function loadClientIps() {
|
|||
|
||||
async function clearClientIps() {
|
||||
if (!clientStats.value?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${clientStats.value.email}`);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/clearClientIps/${clientStats.value.email}`);
|
||||
if (msg?.success) {
|
||||
clientIpsArray.value = [];
|
||||
clientIpsText.value = t('tgbot.noIpRecord');
|
||||
|
|
|
|||
|
|
@ -10,11 +10,9 @@ import {
|
|||
EditOutlined,
|
||||
QrcodeOutlined,
|
||||
CopyOutlined,
|
||||
FileDoneOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
ReloadOutlined,
|
||||
RestOutlined,
|
||||
RetweetOutlined,
|
||||
BlockOutlined,
|
||||
DeleteOutlined,
|
||||
|
|
@ -317,12 +315,6 @@ function showQrCodeMenu(dbInbound) {
|
|||
<a-menu-item key="resetInbounds">
|
||||
<ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
|
@ -390,18 +382,12 @@ function showQrCodeMenu(dbInbound) {
|
|||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
|
|
@ -517,18 +503,12 @@ function showQrCodeMenu(dbInbound) {
|
|||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
|
|
|
|||
|
|
@ -309,20 +309,6 @@ function confirmResetTraffic(dbInbound) {
|
|||
});
|
||||
}
|
||||
|
||||
function confirmDelDepleted(dbInboundId) {
|
||||
Modal.confirm({
|
||||
title: 'Delete depleted clients?',
|
||||
content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Clone — adds a new inbound with the same protocol+stream+sniffing
|
||||
// but a fresh remark/port and an empty client list.
|
||||
function confirmClone(dbInbound) {
|
||||
|
|
@ -375,20 +361,6 @@ function onGeneralAction(key) {
|
|||
},
|
||||
});
|
||||
break;
|
||||
case 'resetClients':
|
||||
Modal.confirm({
|
||||
title: 'Reset all client traffic across all inbounds?',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'delDepletedClients':
|
||||
confirmDelDepleted(-1);
|
||||
break;
|
||||
default:
|
||||
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
|
|
@ -427,20 +399,6 @@ function onRowAction({ key, dbInbound }) {
|
|||
case 'clone':
|
||||
confirmClone(dbInbound);
|
||||
break;
|
||||
case 'resetClients':
|
||||
Modal.confirm({
|
||||
title: `Reset client traffic on "${dbInbound.remark}"?`,
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'delDepletedClients':
|
||||
confirmDelDepleted(dbInbound.id);
|
||||
break;
|
||||
default:
|
||||
message.info(`Action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,12 +126,12 @@ export function useInbounds() {
|
|||
}
|
||||
|
||||
async function fetchOnlineUsers() {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
|
||||
const msg = await HttpUtil.post('/panel/api/clients/onlines');
|
||||
if (msg?.success) onlineClients.value = msg.obj || [];
|
||||
}
|
||||
|
||||
async function fetchLastOnlineMap() {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
|
||||
const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
|
||||
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
|
|
@ -31,6 +34,16 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/:id/detach", a.detach)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/delDepleted", a.delDepleted)
|
||||
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
|
||||
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
|
||||
g.POST("/clientIps/:email", a.getClientIps)
|
||||
g.POST("/clearClientIps/:email", a.clearClientIps)
|
||||
g.POST("/onlines", a.onlines)
|
||||
g.POST("/lastOnline", a.lastOnline)
|
||||
g.GET("/traffic/:email", a.getTrafficByEmail)
|
||||
g.GET("/traffic/byId/:id", a.getTrafficsByClientID)
|
||||
g.GET("/subLinks/:subId", a.getSubLinks)
|
||||
g.GET("/links/:id/:email", a.getClientLinks)
|
||||
}
|
||||
|
||||
func (a *ClientController) list(c *gin.Context) {
|
||||
|
|
@ -168,6 +181,135 @@ func (a *ClientController) delDepleted(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
type trafficUpdateRequest struct {
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
}
|
||||
|
||||
func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var req trafficUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.inboundService.UpdateClientTrafficByEmail(email, req.Upload, req.Download); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getClientIps(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
ips, err := a.inboundService.GetInboundClientIps(email)
|
||||
if err != nil || ips == "" {
|
||||
jsonObj(c, "No IP Record", nil)
|
||||
return
|
||||
}
|
||||
type ipWithTimestamp struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
var ipsWithTime []ipWithTimestamp
|
||||
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
|
||||
formatted := make([]string, 0, len(ipsWithTime))
|
||||
for _, item := range ipsWithTime {
|
||||
if item.IP == "" {
|
||||
continue
|
||||
}
|
||||
if item.Timestamp > 0 {
|
||||
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
|
||||
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||
continue
|
||||
}
|
||||
formatted = append(formatted, item.IP)
|
||||
}
|
||||
jsonObj(c, formatted, nil)
|
||||
return
|
||||
}
|
||||
var oldIps []string
|
||||
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
|
||||
jsonObj(c, oldIps, nil)
|
||||
return
|
||||
}
|
||||
jsonObj(c, ips, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) clearClientIps(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
if err := a.inboundService.ClearClientIps(email); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) onlines(c *gin.Context) {
|
||||
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) lastOnline(c *gin.Context) {
|
||||
data, err := a.inboundService.GetClientsLastOnline()
|
||||
jsonObj(c, data, err)
|
||||
}
|
||||
|
||||
func (a *ClientController) getTrafficByEmail(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
traffic, err := a.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, traffic, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getTrafficsByClientID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
traffics, err := a.inboundService.GetClientTrafficByID(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, traffics, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getSubLinks(c *gin.Context) {
|
||||
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, links, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getClientLinks(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, links, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) detach(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
|
|
@ -63,41 +62,18 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||
|
||||
g.GET("/list", a.getInbounds)
|
||||
g.GET("/get/:id", a.getInbound)
|
||||
g.GET("/getClientTraffics/:email", a.getClientTraffics)
|
||||
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
|
||||
g.GET("/getSubLinks/:subId", a.getSubLinks)
|
||||
g.GET("/getClientLinks/:id/:email", a.getClientLinks)
|
||||
|
||||
g.POST("/add", a.addInbound)
|
||||
g.POST("/del/:id", a.delInbound)
|
||||
g.POST("/update/:id", a.updateInbound)
|
||||
g.POST("/setEnable/:id", a.setInboundEnable)
|
||||
g.POST("/clientIps/:email", a.getClientIps)
|
||||
g.POST("/clearClientIps/:email", a.clearClientIps)
|
||||
g.POST("/addClient", a.addInboundClient)
|
||||
g.POST("/:id/copyClients", a.copyInboundClients)
|
||||
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
||||
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
||||
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
||||
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
|
||||
g.POST("/delDepletedClients/:id", a.delDepletedClients)
|
||||
g.POST("/import", a.importInbound)
|
||||
g.POST("/onlines", a.onlines)
|
||||
g.POST("/lastOnline", a.lastOnline)
|
||||
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
|
||||
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
|
||||
g.GET("/:id/fallbackChildren", a.getFallbackChildren)
|
||||
g.POST("/:id/fallbackChildren", a.setFallbackChildren)
|
||||
}
|
||||
|
||||
type CopyInboundClientsRequest struct {
|
||||
SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"`
|
||||
ClientEmails []string `form:"clientEmails" json:"clientEmails"`
|
||||
Flow string `form:"flow" json:"flow"`
|
||||
}
|
||||
|
||||
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
|
|
@ -124,28 +100,6 @@ func (a *InboundController) getInbound(c *gin.Context) {
|
|||
jsonObj(c, inbound, nil)
|
||||
}
|
||||
|
||||
// getClientTraffics retrieves client traffic information by email.
|
||||
func (a *InboundController) getClientTraffics(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
clientTraffics, err := a.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, clientTraffics, nil)
|
||||
}
|
||||
|
||||
// getClientTrafficsById retrieves client traffic information by inbound ID.
|
||||
func (a *InboundController) getClientTrafficsById(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
clientTraffics, err := a.inboundService.GetClientTrafficByID(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, clientTraffics, nil)
|
||||
}
|
||||
|
||||
// addInbound creates a new inbound configuration.
|
||||
func (a *InboundController) addInbound(c *gin.Context) {
|
||||
inbound := &model.Inbound{}
|
||||
|
|
@ -277,174 +231,6 @@ func (a *InboundController) setInboundEnable(c *gin.Context) {
|
|||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||
}
|
||||
|
||||
// getClientIps retrieves the IP addresses associated with a client by email.
|
||||
func (a *InboundController) getClientIps(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
|
||||
ips, err := a.inboundService.GetInboundClientIps(email)
|
||||
if err != nil || ips == "" {
|
||||
jsonObj(c, "No IP Record", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer returning a normalized string list for consistent UI rendering
|
||||
type ipWithTimestamp struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var ipsWithTime []ipWithTimestamp
|
||||
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
|
||||
formatted := make([]string, 0, len(ipsWithTime))
|
||||
for _, item := range ipsWithTime {
|
||||
if item.IP == "" {
|
||||
continue
|
||||
}
|
||||
if item.Timestamp > 0 {
|
||||
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
|
||||
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||
continue
|
||||
}
|
||||
formatted = append(formatted, item.IP)
|
||||
}
|
||||
jsonObj(c, formatted, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var oldIps []string
|
||||
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
|
||||
jsonObj(c, oldIps, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// If parsing fails, return as string
|
||||
jsonObj(c, ips, nil)
|
||||
}
|
||||
|
||||
// clearClientIps clears the IP addresses for a client by email.
|
||||
func (a *InboundController) clearClientIps(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
|
||||
err := a.inboundService.ClearClientIps(email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
|
||||
}
|
||||
|
||||
// addInboundClient adds a new client to an existing inbound.
|
||||
func (a *InboundController) addInboundClient(c *gin.Context) {
|
||||
data := &model.Inbound{}
|
||||
err := c.ShouldBind(data)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
|
||||
needRestart, err := a.inboundService.AddInboundClient(data)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// copyInboundClients copies clients from source inbound to target inbound.
|
||||
func (a *InboundController) copyInboundClients(c *gin.Context) {
|
||||
targetID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
req := &CopyInboundClientsRequest{}
|
||||
err = c.ShouldBind(req)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if req.SourceInboundID <= 0 {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
|
||||
return
|
||||
}
|
||||
|
||||
result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
|
||||
func (a *InboundController) delInboundClient(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
clientId := c.Param("clientId")
|
||||
|
||||
needRestart, err := a.inboundService.DelInboundClient(id, clientId)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// updateInboundClient updates a client's configuration in an inbound.
|
||||
func (a *InboundController) updateInboundClient(c *gin.Context) {
|
||||
clientId := c.Param("clientId")
|
||||
|
||||
inbound := &model.Inbound{}
|
||||
err := c.ShouldBind(inbound)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
|
||||
needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// resetClientTraffic resets the traffic counter for a specific client in an inbound.
|
||||
func (a *InboundController) resetClientTraffic(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
email := c.Param("email")
|
||||
|
||||
needRestart, err := a.inboundService.ResetClientTraffic(id, email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// resetInboundTraffic resets traffic counters for a specific inbound.
|
||||
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
|
|
@ -525,79 +311,6 @@ func (a *InboundController) importInbound(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// delDepletedClients deletes clients in an inbound who have exhausted their traffic limits.
|
||||
func (a *InboundController) delDepletedClients(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
err = a.inboundService.DelDepletedClients(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
|
||||
}
|
||||
|
||||
// onlines retrieves the list of currently online clients.
|
||||
func (a *InboundController) onlines(c *gin.Context) {
|
||||
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
||||
}
|
||||
|
||||
// lastOnline retrieves the last online timestamps for clients.
|
||||
func (a *InboundController) lastOnline(c *gin.Context) {
|
||||
data, err := a.inboundService.GetClientsLastOnline()
|
||||
jsonObj(c, data, err)
|
||||
}
|
||||
|
||||
// updateClientTraffic updates the traffic statistics for a client by email.
|
||||
func (a *InboundController) updateClientTraffic(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
|
||||
// Define the request structure for traffic update
|
||||
type TrafficUpdateRequest struct {
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
}
|
||||
|
||||
var request TrafficUpdateRequest
|
||||
err := c.ShouldBindJSON(&request)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
|
||||
err = a.inboundService.UpdateClientTrafficByEmail(email, request.Upload, request.Download)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||
}
|
||||
|
||||
// delInboundClientByEmail deletes a client from an inbound by email address.
|
||||
func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
||||
inboundId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, "Invalid inbound ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
email := c.Param("email")
|
||||
needRestart, err := a.inboundService.DelInboundClientByEmail(inboundId, email)
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to delete client by email", err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonMsg(c, "Client deleted successfully", nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
|
||||
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
|
||||
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
|
||||
|
|
@ -624,17 +337,6 @@ func resolveHost(c *gin.Context) string {
|
|||
return c.Request.Host
|
||||
}
|
||||
|
||||
// getSubLinks returns every protocol URL produced for the given subscription
|
||||
// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
|
||||
func (a *InboundController) getSubLinks(c *gin.Context) {
|
||||
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, links, nil)
|
||||
}
|
||||
|
||||
func (a *InboundController) getFallbackChildren(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
|
|
@ -671,19 +373,3 @@ func (a *InboundController) setFallbackChildren(c *gin.Context) {
|
|||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
||||
}
|
||||
|
||||
// getClientLinks returns the URL(s) for one client on one inbound — the same
|
||||
// string the Copy URL button copies in the panel UI. Empty array when the
|
||||
// protocol has no URL form, or when the email isn't found on the inbound.
|
||||
func (a *InboundController) getClientLinks(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, links, nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -475,6 +475,37 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
|
|||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
inboundIds, err := s.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(inboundIds) == 0 {
|
||||
if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
|
||||
return false, rErr
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
nr, rErr := inboundSvc.ResetClientTraffic(ibId, email)
|
||||
if rErr != nil {
|
||||
return needRestart, rErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
|
||||
db := database.GetDB()
|
||||
now := time.Now().UnixMilli()
|
||||
|
|
|
|||
Loading…
Reference in a new issue