feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)
This commit is contained in:
@@ -280,7 +280,7 @@ export async function fetchAllSubscriptions(client, token) {
|
||||
// Learning normalizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc'];
|
||||
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc', 'src', 'lnk'];
|
||||
const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g');
|
||||
const BARE_UUID_RE = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g;
|
||||
const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g;
|
||||
@@ -424,6 +424,10 @@ export class Normalizer {
|
||||
return out;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
// Speeds are derived from adjacent usage reports over wall-clock
|
||||
// time (dt differs per side by construction), so only their shape
|
||||
// is comparable: positive vs zero. See UI-RESKIN-REPORT.md.
|
||||
if (keyName === 'speedUpBps' || keyName === 'speedDownBps') return value > 0 ? '<speed+>' : 0;
|
||||
if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
// executed and the first differing JSON path is reported per step.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
import {
|
||||
ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient,
|
||||
Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff,
|
||||
@@ -284,6 +287,201 @@ async function runPhase2(runner, { seedLog, seedExpect }) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3: UI-reskin features — node reorder, presence/speed, /api/system,
|
||||
// customer-form node alignment, credentials, subscription aggregation.
|
||||
// Time-derived values (speeds, /api/system numbers) are compared by shape
|
||||
// only; everything else is compared exactly like phase 2.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AGG_FIXTURE_BODY = Buffer.from([
|
||||
'ss://YWVzLTI1Ni1nY206ZXh0LXB3@ext.example.com:8388#ExtOne',
|
||||
'vless://22222222-2222-2222-2222-222222222222@ext2.example.com:443?security=none#ExtTwo',
|
||||
'trojan://x@bad.example.com:443#Dropped',
|
||||
'',
|
||||
].join('\n')).toString('base64');
|
||||
const AGG_MANUAL_LINK = 'ss://YWVzLTI1Ni1nY206bWFudWFsLXB3@manual.example.com:8388#ManualOne';
|
||||
|
||||
async function runPhase3(runner) {
|
||||
// --- node reorder + subscription order ----------------------------------
|
||||
await runner.step('p3/reorder-nodes', async (side) => {
|
||||
const res = await side.client.admin('POST', '/api/nodes/reorder', { ids: [side.ids.n2, side.ids.n1] });
|
||||
const list = await side.client.admin('GET', '/api/nodes');
|
||||
return {
|
||||
reorder: view(res),
|
||||
order: list.json.nodes.map((n) => n.name),
|
||||
sortIndexes: list.json.nodes.map((n) => n.sortIndex),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/alice-sub-order-after-reorder', async (side) => {
|
||||
const res = await side.client.getSubscription(side.subTokens.alice, 'raw', UA_DEFAULT);
|
||||
const names = res.body.split('\n').filter(Boolean).map((line) => {
|
||||
const idx = line.lastIndexOf('#');
|
||||
return idx >= 0 ? decodeURIComponent(line.slice(idx + 1)) : line;
|
||||
});
|
||||
return { status: res.status, names };
|
||||
});
|
||||
|
||||
// --- presence & speed: two usage reports ~6s apart -----------------------
|
||||
await runner.step('p3/usage-report-1', async (side) =>
|
||||
view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
|
||||
epoch: 'diff-phase2-epoch-1',
|
||||
samples: [
|
||||
{ kind: 'user', id: side.ids.alice, uplink: 1500, downlink: 2100 },
|
||||
{ kind: 'inbound', id: side.ids.n1, uplink: 5000, downlink: 6000 },
|
||||
],
|
||||
})));
|
||||
await runner.step('p3/usage-report-2-after-6s', async (side) => {
|
||||
await sleep(6000);
|
||||
return view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
|
||||
epoch: 'diff-phase2-epoch-1',
|
||||
samples: [
|
||||
{ kind: 'user', id: side.ids.alice, uplink: 4500, downlink: 6300 },
|
||||
{ kind: 'inbound', id: side.ids.n1, uplink: 8000, downlink: 9000 },
|
||||
],
|
||||
}));
|
||||
});
|
||||
await runner.step('p3/presence-projection', async (side) => {
|
||||
const customers = await side.client.admin('GET', '/api/customers');
|
||||
const nodes = await side.client.admin('GET', '/api/nodes');
|
||||
const nameOf = (id) => { const c = customers.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
|
||||
return {
|
||||
customers: customers.json.customers.map((c) => ({
|
||||
name: c.name, online: c.online,
|
||||
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
|
||||
hasLastActiveAt: Boolean(c.lastActiveAt),
|
||||
})),
|
||||
nodes: nodes.json.nodes.map((n) => ({
|
||||
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
|
||||
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
|
||||
speedPositive: (n.speedUpBps || 0) > 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/system-shape', async (side) => {
|
||||
const res = await side.client.admin('GET', '/api/system');
|
||||
const s = res.json || {};
|
||||
return {
|
||||
status: res.status, version: s.version,
|
||||
hostKeys: s.host ? Object.keys(s.host).sort() : null,
|
||||
networkKeys: s.network ? Object.keys(s.network).sort() : null,
|
||||
todayKeys: s.today ? Object.keys(s.today).sort() : null,
|
||||
ipsKeys: s.ips ? Object.keys(s.ips).sort() : null,
|
||||
historyIsArray: Array.isArray(s.history),
|
||||
connectionsIsNumber: Boolean(s.network) && typeof s.network.connections === 'number',
|
||||
panelMemPositive: s.panelMemBytes > 0,
|
||||
startedAtPresent: typeof s.startedAt === 'string',
|
||||
};
|
||||
});
|
||||
|
||||
// --- customer-form node alignment (on Carol: no golden subscriptions) ----
|
||||
await runner.step('p3/align-carol-add-n1', async (side) => {
|
||||
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [side.ids.n1] });
|
||||
const nodes = await side.client.admin('GET', '/api/nodes');
|
||||
const mine = nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol);
|
||||
if (mine.length) side.ids.carolNc = mine[0].id;
|
||||
return {
|
||||
status: res.status, count: mine.length,
|
||||
nodeNames: mine.map((nc) => { const n = nodes.json.nodes.find((x) => x.id === nc.nodeId); return n ? n.name : '?'; }),
|
||||
statuses: mine.map((nc) => nc.status),
|
||||
};
|
||||
});
|
||||
|
||||
// --- credentials ----------------------------------------------------------
|
||||
await runner.step('p3/credentials-get', async (side) => {
|
||||
const res = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
||||
return {
|
||||
status: res.status,
|
||||
hasToken: typeof res.json.subscriptionToken === 'string' && res.json.subscriptionToken.length > 10,
|
||||
items: (res.json.items || []).map((entry) => ({
|
||||
nodeName: entry.nodeName, protocol: entry.protocol, status: entry.status,
|
||||
hasClientId: Boolean(entry.clientId), hasPassword: Boolean(entry.password),
|
||||
hasShortId: Boolean(entry.shortId), username: entry.username,
|
||||
})),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/credentials-regenerate', async (side) => {
|
||||
const before = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
||||
const regen = await side.client.admin('POST', `/api/customers/${side.ids.carol}/credentials/${side.ids.carolNc}/regenerate`, {});
|
||||
const after = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
||||
const b = (before.json.items || [])[0] || {};
|
||||
const a = (after.json.items || [])[0] || {};
|
||||
return {
|
||||
status: regen.status,
|
||||
changed: b.clientId !== a.clientId || b.password !== a.password,
|
||||
itemNodeName: regen.json && regen.json.item ? regen.json.item.nodeName : null,
|
||||
itemHasClientId: Boolean(regen.json && regen.json.item && regen.json.item.clientId),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/align-carol-remove-all', async (side) => {
|
||||
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [] });
|
||||
const nodes = await side.client.admin('GET', '/api/nodes');
|
||||
return {
|
||||
status: res.status,
|
||||
bindingsForCarol: nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol).length,
|
||||
};
|
||||
});
|
||||
|
||||
// --- subscription aggregation ---------------------------------------------
|
||||
// Both panels must allow private subscription sources to reach the
|
||||
// loopback fixture: flip the setting in each side's data file and
|
||||
// restart (there is deliberately no settings API).
|
||||
const fixture = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end(AGG_FIXTURE_BODY);
|
||||
});
|
||||
await new Promise((resolve) => fixture.listen(0, '127.0.0.1', resolve));
|
||||
const fixtureUrl = `http://127.0.0.1:${fixture.address().port}/sub`;
|
||||
try {
|
||||
for (const side of [runner.sideA, runner.sideB]) {
|
||||
await stopPanel(side.proc);
|
||||
const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8'));
|
||||
db.settings = db.settings || {};
|
||||
db.settings.allowPrivateSubSources = true;
|
||||
fs.writeFileSync(side.dataFile, JSON.stringify(db));
|
||||
await restartSide(side);
|
||||
}
|
||||
await runner.step('p3/agg-relogin', async (side) => view(await side.client.login()));
|
||||
await runner.step('p3/agg-create-dave', async (side) => {
|
||||
const res = await side.client.admin('POST', '/api/customers', {
|
||||
name: 'Dave',
|
||||
manualLinks: [{ uri: AGG_MANUAL_LINK, remark: '手动一号', enabled: true }],
|
||||
externalSources: [{ url: fixtureUrl, remark: '机场', enabled: true }],
|
||||
});
|
||||
if (res.json && res.json.customer) {
|
||||
side.ids.dave = res.json.customer.id;
|
||||
side.subTokens.dave = res.json.customer.subscriptionToken;
|
||||
const source = (res.json.customer.externalSources || [])[0];
|
||||
if (source) side.ids.daveSrc = source.id;
|
||||
}
|
||||
return {
|
||||
status: res.status,
|
||||
manualCount: res.json && res.json.customer ? (res.json.customer.manualLinks || []).length : null,
|
||||
sourceCount: res.json && res.json.customer ? (res.json.customer.externalSources || []).length : null,
|
||||
};
|
||||
});
|
||||
await runner.step('p3/agg-refresh', async (side) =>
|
||||
view(await side.client.admin('POST', `/api/customers/${side.ids.dave}/external-sources/${side.ids.daveSrc}/refresh`, {})));
|
||||
await runner.step('p3/agg-customer-status', async (side) => {
|
||||
const res = await side.client.admin('GET', '/api/customers');
|
||||
const dave = res.json.customers.find((c) => c.id === side.ids.dave);
|
||||
const status = dave && dave.externalStatus ? Object.values(dave.externalStatus)[0] : null;
|
||||
return {
|
||||
hasExternalStatus: Boolean(dave && dave.externalStatus),
|
||||
count: status ? status.count : null,
|
||||
dropped: status ? status.droppedCount : null,
|
||||
failCount: status ? status.failCount : null,
|
||||
hasOkAt: Boolean(status && status.okAt),
|
||||
lastError: status ? status.lastError : 'missing',
|
||||
};
|
||||
});
|
||||
await runner.step('p3/agg-subscriptions', async (side) =>
|
||||
fetchAllSubscriptions(side.client, side.subTokens.dave));
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB interoperability: swap the two panels' DB files and keep working.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -360,6 +558,7 @@ async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExp
|
||||
try {
|
||||
const runner = new StepRunner(reporter, sideA, sideB);
|
||||
await runPhase2(runner, { seedLog, seedExpect });
|
||||
await runPhase3(runner);
|
||||
await runInterop(reporter, sideA, sideB, { seedExpect });
|
||||
} finally {
|
||||
await destroySide(sideA, keep);
|
||||
|
||||
Reference in New Issue
Block a user