feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)
This commit is contained in:
+125
-39
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user