feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)

This commit is contained in:
aaaamoooo
2026-10-21 03:16:30 +08:00
parent c6f6b9dbea
commit d287bb9be9
23 changed files with 3370 additions and 146 deletions
+125 -39
View File
@@ -240,6 +240,9 @@ function validateNodeDeploy(data, node) {
else validateProtocolPair(node.protocol, node.exitProtocol);
const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器');
const exitServer = direct ? null : assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器');
for (const target of direct ? [server] : [server, exitServer]) {
if (target.enabled === false) { const error = new Error(`设备 ${target.name} 已停用`); error.statusCode = 409; throw error; }
}
if (!activeNodeClients(data, node).length) { const error = new Error('请先给节点分配至少一个客户再部署'); error.statusCode = 400; throw error; }
if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) {
if (!server.tlsDomain) { const error = new Error(`${node.protocol} 需要先填写设备 TLS 域名并在节点上安装证书`); error.statusCode = 400; throw error; }
@@ -281,6 +284,8 @@ function validateNodeDeploy(data, node) {
// gates on "no in-flight jobs" instead and passes failInFlight: false.
async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, failInFlight = true } = {}) {
const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器');
// A disabled device receives no new deployments, including reconciles.
if (server.enabled === false) return 0;
if (failInFlight) {
for (const job of data.jobs.filter((item) => item.serverId === serverId && ['queued', 'running'].includes(item.status))) {
job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso();
@@ -314,6 +319,90 @@ async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null,
return count;
}
// Data-level assign/unassign (the transaction-free cores of the
// Orchestrator methods) so the customer form's node alignment can run
// inside the customer create/PATCH transaction.
async function assignCustomerData(data, nodeId, customerId, actor = 'admin') {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
if (nc) {
nc.status = 'active'; nc.updatedAt = nowIso();
} else {
nc = {
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
}
// A manually undeployed node stays draft until explicitly deployed.
// Automatic teardowns (suspend) are revived when clients return.
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
// record the assignment only
} else {
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
return structuredClone(nc);
}
async function unassignCustomerData(data, nodeId, customerId, actor = 'admin') {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
data.nodeClients.splice(index, 1);
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
audit(data, actor, 'unassign_customer', node.id, { customerId });
return true;
}
// Full-set alignment used by the customer form's 关联入站 multi-select:
// bindings not in desiredIds are removed, missing ones are created.
// Suspended customers get suspended bindings (mirroring their resource
// state) without touching node resources.
async function alignCustomerNodes(data, customer, desiredIds, actor = 'admin') {
const desired = [...new Set((desiredIds || []).map(String).filter(Boolean))];
for (const nodeId of desired) {
const node = data.nodes.find((n) => n.id === nodeId && !n.pendingDelete);
if (!node) { const error = new Error('节点不存在'); error.statusCode = 400; throw error; }
}
const current = data.nodeClients.filter((nc) => nc.customerId === customer.id);
const rebuild = new Set();
for (const nc of [...current]) {
if (desired.includes(nc.nodeId)) continue;
data.nodeClients.splice(data.nodeClients.indexOf(nc), 1);
audit(data, actor, 'unassign_customer', nc.nodeId, { customerId: customer.id });
rebuild.add(nc.nodeId);
}
for (const nodeId of desired) {
if (current.some((nc) => nc.nodeId === nodeId)) continue;
const node = data.nodes.find((n) => n.id === nodeId);
const nc = {
id: id('ncl'), nodeId, customerId: customer.id, credentials: newNodeClientCredentials(node.protocol),
status: customer.status === 'active' ? 'active' : 'suspended', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
audit(data, actor, 'assign_customer', node.id, { customerId: customer.id, customer: customer.name });
if (nc.status === 'active') rebuild.add(nodeId);
}
for (const nodeId of rebuild) {
const node = data.nodes.find((n) => n.id === nodeId);
if (!node) continue;
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) continue;
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
}
class Orchestrator {
constructor(store) {
this.store = store;
@@ -365,47 +454,11 @@ class Orchestrator {
}
async assignCustomer(nodeId, customerId, actor = 'admin') {
return this.store.transaction(async (data) => {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
if (nc) {
nc.status = 'active'; nc.updatedAt = nowIso();
} else {
nc = {
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
}
// A manually undeployed node stays draft until explicitly deployed.
// Automatic teardowns (suspend) are revived when clients return.
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
// record the assignment only
} else {
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
return structuredClone(nc);
});
return this.store.transaction(async (data) => assignCustomerData(data, nodeId, customerId, actor));
}
async unassignCustomer(nodeId, customerId, actor = 'admin') {
return this.store.transaction(async (data) => {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
data.nodeClients.splice(index, 1);
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
audit(data, actor, 'unassign_customer', node.id, { customerId });
return true;
});
return this.store.transaction(async (data) => unassignCustomerData(data, nodeId, customerId, actor));
}
async reconcileServer(serverId, actor = 'system', options = {}) {
@@ -516,8 +569,27 @@ class Orchestrator {
const key = `srv:${serverId}`;
customer.serverUsage = customer.serverUsage || {};
const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {});
const prevCounters = slot.usageCounters;
const prevSampleAt = slot.lastSampleAt;
const delta = applyDelta(slot, uplink, downlink, sampleEpoch);
if (!delta) continue;
slot.lastSampleAt = reportedAt;
// Presence + approximate speed from the same delta (no extra
// sampling channel): a positive delta is activity; speed is the
// delta over the gap since the previous processed sample.
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
if (delta.upDelta + delta.downDelta > 0) {
customer.lastOnlineAt = reportedAt;
slot.lastActiveAt = reportedAt;
}
if (prevSampleAt && !restarted) {
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
if (dt >= 5) {
slot.speedUpBps = Math.round(delta.upDelta / dt);
slot.speedDownBps = Math.round(delta.downDelta / dt);
slot.speedAt = reportedAt;
}
}
customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta;
customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta;
customer.usedBytes = (customer.usedBytes || 0) + delta.upDelta + delta.downDelta;
@@ -528,8 +600,21 @@ class Orchestrator {
// transport and is never double counted (same rule as before).
const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId);
if (!node) continue;
const prevCounters = node.usageCounters;
const prevSampleAt = node.lastSampleAt;
const delta = applyDelta(node, uplink, downlink, sampleEpoch);
if (!delta) continue;
node.lastSampleAt = reportedAt;
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
if (delta.upDelta + delta.downDelta > 0) node.lastActiveAt = reportedAt;
if (prevSampleAt && !restarted) {
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
if (dt >= 5) {
node.speedUpBps = Math.round(delta.upDelta / dt);
node.speedDownBps = Math.round(delta.downDelta / dt);
node.speedAt = reportedAt;
}
}
const metered = node.meteredTraffic || { uplink: 0, downlink: 0 };
node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta };
node.lastUsageAt = reportedAt;
@@ -541,4 +626,5 @@ class Orchestrator {
module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode,
resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients,
suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS };
suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS,
assignCustomerData, unassignCustomerData, alignCustomerNodes };