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
+5 -1
View File
@@ -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;
}
+199
View File
@@ -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);