feat(panel): customers stats strip with 3x-ui depletion semantics (round-2 #1)
GET /api/customers gains a summary object (total/online/depleted/ depletingSoon/disabled/enabled) computed server-side; the customers page renders it as a six-cell stats strip above the toolbar. Predicates aligned verbatim with 3x-ui client_paging.go: - depleted = (limit>0 && used>=limit) || (expiry set && expiry<=now), counted regardless of status; - depletingSoon = active && !depleted && (expiry within expireDiff days || remaining traffic < trafficDiff GB), thresholds from new settings expireDiff/trafficDiff with 3x-ui's factory default 0 (nobody counts until configured); - enabled = status active; disabled = manual suspensions only. Online reuses the existing 3-minute presence projection. Golden diff confined to the additive summary keys; summary_test.go covers every bucket with thresholds off (soon=0) and on (soon=2).
This commit is contained in:
+14
-4
@@ -252,17 +252,17 @@ async function load(page = state.page) {
|
||||
if (page === 'overview') {
|
||||
const [overview, system, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/system'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]);
|
||||
state.overview = overview; state.system = system; state.version = overview.version || state.version;
|
||||
state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
$('#version-label').textContent = `v${state.version}`;
|
||||
} else if (page === 'servers') {
|
||||
const [servers, routes] = await Promise.all([api('/api/servers'), api('/api/nodes')]);
|
||||
state.servers = servers.servers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
} else if (page === 'customers') {
|
||||
const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]);
|
||||
state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
} else if (page === 'chains') {
|
||||
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]);
|
||||
state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
|
||||
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
|
||||
} else if (page === 'commands') { /* 静态页 */ }
|
||||
else if (page === 'operations') {
|
||||
@@ -552,7 +552,17 @@ function renderCustomers() {
|
||||
<td>${remainingPill(item)}</td>
|
||||
<td>${durationPill(item)}</td></tr>`;
|
||||
}).join('');
|
||||
return `<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
|
||||
const sum = state.customerSummary;
|
||||
const statCell = (lab, cls, svg, val) => `<div class="stat"><div class="lab">${lab}</div><div class="val"><svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${svg}</svg>${val}</div></div>`;
|
||||
const custStats = sum ? `<section class="stats six">
|
||||
${statCell('客户端', 'ic-dim', '<circle cx="9" cy="8" r="3.5"/><path d="M3.5 19c.6-3 2.8-4.5 5.5-4.5s4.9 1.5 5.5 4.5"/><path d="M16 5.5a3.5 3.5 0 010 6.5M17.5 14.7c1.7.6 2.7 1.9 3 4.3"/>', sum.total)}
|
||||
${statCell('在线', 'ic-green', '<path d="M3 12h4l2.5-6 4 12L16 12h5"/>', sum.online)}
|
||||
${statCell('耗尽', 'ic-red', '<rect x="3" y="8" width="15" height="8" rx="2"/><path d="M21 11v2"/><path d="M6.5 11v2"/>', sum.depleted)}
|
||||
${statCell('即将耗尽', 'ic-bolt', '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', sum.depletingSoon)}
|
||||
${statCell('关闭', 'ic-red', '<path d="M12 3v8"/><path d="M6.3 6.5a8 8 0 1 0 11.4 0"/>', sum.disabled)}
|
||||
${statCell('启用', 'ic-green', '<circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 4.5-5.5"/>', sum.enabled)}
|
||||
</section>` : '';
|
||||
return `${custStats}<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
|
||||
<div class="toolbar"><input class="search" data-search placeholder="搜索客户、备注、分组或标签" value="${esc(state.search)}">
|
||||
<select class="search" data-group-filter><option value="">全部分组</option>${groups.map((g) => `<option value="${esc(g)}"${selected(g, state.groupFilter)}>${esc(g)}</option>`).join('')}</select>
|
||||
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" data-tip="清除">×</button></span>` : ''}
|
||||
|
||||
Reference in New Issue
Block a user