Persist traffic settings and support multi-device traffic views
This commit is contained in:
@@ -206,3 +206,48 @@ test('route is unavailable when no traffic collector exists', async () => {
|
||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
|
||||
test('PUT persists validated traffic settings with revision protection and returns canonical state', async () => {
|
||||
let state = { revision: 4, traffic: { grouping: 'site', sort: 'popular', retentionSeconds: 10 } };
|
||||
let sent = false;
|
||||
const route = createLiveTrafficRoute({
|
||||
traffic: null,
|
||||
settingsState: {
|
||||
read: () => state,
|
||||
update: (mutator) => { state = { ...mutator(state), revision: state.revision + 1 }; },
|
||||
},
|
||||
readBody: async () => ({
|
||||
settings: { grouping: 'device', sort: 'recent', retentionSeconds: 30 },
|
||||
expectedRevision: 4,
|
||||
}),
|
||||
sendState: async (res) => { sent = true; res.end(JSON.stringify(state)); },
|
||||
});
|
||||
const res = response();
|
||||
|
||||
assert.equal(await route.handle({ method: 'PUT', url: '/api/traffic/settings' }, res), true);
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(state.traffic, { grouping: 'device', sort: 'recent', retentionSeconds: 30 });
|
||||
assert.equal(state.revision, 5);
|
||||
|
||||
const conflict = createLiveTrafficRoute({
|
||||
traffic: null,
|
||||
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
||||
readBody: async () => ({ settings: state.traffic, expectedRevision: 4 }),
|
||||
sendState: async () => {},
|
||||
});
|
||||
await assert.rejects(
|
||||
conflict.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
||||
(error) => error.code === 'STATE_CONFLICT',
|
||||
);
|
||||
|
||||
const invalid = createLiveTrafficRoute({
|
||||
traffic: null,
|
||||
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
||||
readBody: async () => ({ settings: { grouping: 'guess' }, expectedRevision: 5 }),
|
||||
sendState: async () => {},
|
||||
});
|
||||
await assert.rejects(
|
||||
invalid.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -339,6 +339,7 @@ setInterval(() => {}, 60_000);
|
||||
'singboxStartedAt',
|
||||
'subscription',
|
||||
'subscriptionHost',
|
||||
'traffic',
|
||||
'userInfo',
|
||||
]);
|
||||
let revision = initial.revision;
|
||||
|
||||
@@ -137,13 +137,14 @@ test('schema v7 migrates failover disabled without losing canonical state', (t)
|
||||
});
|
||||
const migrated = store.read();
|
||||
|
||||
assert.equal(migrated.schemaVersion, 8);
|
||||
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||
assert.equal(migrated.revision, 19);
|
||||
assert.equal(migrated.routeRulesRevision, 4);
|
||||
assert.equal(migrated.failoverPolicy.enabled, false);
|
||||
assert.equal(migrated.failoverRuntimeState.lastSwitchAt, null);
|
||||
assert.equal(migrated.appliedFailoverPolicy, null);
|
||||
assert.deepEqual(migrated.diagnostics.hiddenServiceIds, ['google']);
|
||||
assert.deepEqual(migrated.traffic, { grouping: 'site', sort: 'popular', retentionSeconds: 10 });
|
||||
assert.equal(store.migration.fromVersion, 7);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
groupTrafficConnections,
|
||||
reconcileTrafficGroups,
|
||||
sortTrafficGroups,
|
||||
summarizeTrafficOrigins,
|
||||
trafficConnectionMatchesFilters,
|
||||
trafficGroupMatches,
|
||||
} from '../../.test-dist/src/web/features/traffic/trafficRows.js';
|
||||
|
||||
@@ -176,7 +178,8 @@ test('traffic drawer exposes the requested truthful states and accessible contro
|
||||
assert.match(feature, /\{feature\.isGateway \? 'GATEWAY' : 'MAC'\} · \{snapshot\?\.summary\.active \|\| 0\} АКТИВНЫХ/);
|
||||
assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./);
|
||||
assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./);
|
||||
assert.match(feature, /type="search"[\s\S]*aria-label="Найти домен, сервис или IP"/);
|
||||
assert.match(feature, /aria-label="Найти устройство"/);
|
||||
assert.match(feature, /aria-label="Найти сайт, IP или сервис"/);
|
||||
assert.match(feature, /role="group" aria-label="Фильтр по маршруту"/);
|
||||
assert.match(feature, /role="group" aria-label="Фильтр по качеству распознавания"/);
|
||||
assert.match(feature, /aria-pressed=\{routeFilter === value\}/);
|
||||
@@ -187,19 +190,19 @@ test('traffic drawer exposes the requested truthful states and accessible contro
|
||||
assert.doesNotMatch(feature, /closeConnection|reroute|history|sessionStorage/);
|
||||
});
|
||||
|
||||
test('traffic retention is local, bounded to approved choices and uses the frozen server observation clock', () => {
|
||||
assert.match(feature, /const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds'/);
|
||||
assert.match(feature, /const RETENTION_OPTIONS = \[5, 10, 30\] as const/);
|
||||
assert.match(feature, /Number\(localStorage\.getItem\(RETENTION_STORAGE_KEY\)\)/);
|
||||
assert.match(feature, /RETENTION_OPTIONS\.includes\(value as RetentionSeconds\)[\s\S]*: 10/);
|
||||
assert.match(feature, /localStorage\.setItem\(RETENTION_STORAGE_KEY, String\(seconds\)\)/);
|
||||
assert.match(feature, /Показывать завершённые/);
|
||||
test('traffic retention and grouping use canonical server settings and the frozen server observation clock', () => {
|
||||
assert.doesNotMatch(feature, /localStorage|sessionStorage/);
|
||||
assert.match(feature, /TRAFFIC_RETENTION_OPTIONS\.map/);
|
||||
assert.match(feature, /feature\.updateSettings\(settings\)/);
|
||||
assert.match(api, /updateSettings:[\s\S]*\/api\/traffic\/settings[\s\S]*expectedRevision/);
|
||||
assert.match(app, /onUpdateTrafficSettings=[\s\S]*api\.traffic\.updateSettings/);
|
||||
assert.match(feature, /Завершённые/);
|
||||
assert.match(feature, /aria-label="Время показа завершённых соединений"/);
|
||||
assert.match(feature, /snapshot\?\.observedAt \? Date\.parse\(snapshot\.observedAt\) : Number\.NaN/);
|
||||
assert.match(feature, /connection\.closedAt === null \|\| !Number\.isFinite\(snapshotTime\)/);
|
||||
assert.match(feature, /snapshotTime - Date\.parse\(connection\.closedAt\) < retentionSeconds \* 1_000/);
|
||||
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
|
||||
assert.match(feature, /trafficGroups\.filter\([\s\S]*trafficGroupMatches/);
|
||||
assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/);
|
||||
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
|
||||
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
|
||||
assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./);
|
||||
});
|
||||
@@ -283,12 +286,47 @@ test('traffic groups sort by bounded frequency or latest start without changing
|
||||
'popular.test',
|
||||
]);
|
||||
assert.deepEqual(groups.map(({ label }) => label), original);
|
||||
assert.match(feature, /const \[sortMode, setSortMode\] = useState<TrafficSort>\('popular'\)/);
|
||||
assert.match(feature, /const \{ grouping, sort: sortMode, retentionSeconds \} = feature\.settings/);
|
||||
assert.match(feature, /role="group" aria-label="Сортировка соединений"/);
|
||||
assert.match(feature, /\['popular', 'Популярные'\][\s\S]*\['recent', 'Последние'\]/);
|
||||
assert.match(feature, /aria-pressed=\{sortMode === value\}/);
|
||||
});
|
||||
|
||||
test('site grouping combines devices with exact per-device totals while device grouping keeps them separate', () => {
|
||||
const first = trafficConnection('phone', {
|
||||
origin: { kind: 'device', id: 'dev_0000000000000001', label: 'Телефон', provenance: 'source-ip' },
|
||||
source: { ip: '192.168.50.10' },
|
||||
destination: { domain: 'youtube.com' },
|
||||
traffic: { uploadBytes: '10', downloadBytes: '20' },
|
||||
});
|
||||
const second = trafficConnection('tv', {
|
||||
origin: { kind: 'device', id: 'dev_0000000000000002', label: 'ТВ', provenance: 'source-ip' },
|
||||
source: { ip: '192.168.50.11' },
|
||||
destination: { domain: 'youtube.com' },
|
||||
traffic: { uploadBytes: '30', downloadBytes: '40' },
|
||||
});
|
||||
|
||||
assert.equal(groupTrafficConnections([first, second], 'device').length, 2);
|
||||
const [site] = groupTrafficConnections([first, second], 'site');
|
||||
assert.equal(site.connections.length, 2);
|
||||
assert.equal(site.origins.length, 2);
|
||||
assert.deepEqual(site.traffic, {
|
||||
uploadBytes: '40',
|
||||
downloadBytes: '60',
|
||||
uploadBytesPerSecond: '2',
|
||||
downloadBytesPerSecond: '4',
|
||||
});
|
||||
assert.deepEqual(site.origins.map(({ label, connections, traffic }) => ({ label, connections, traffic })), [
|
||||
{ label: 'ТВ', connections: 1, traffic: { uploadBytes: '30', downloadBytes: '40' } },
|
||||
{ label: 'Телефон', connections: 1, traffic: { uploadBytes: '10', downloadBytes: '20' } },
|
||||
]);
|
||||
|
||||
const ranked = summarizeTrafficOrigins([first, first, second]);
|
||||
assert.deepEqual(ranked.map(({ label, connections }) => [label, connections]), [['Телефон', 2], ['ТВ', 1]]);
|
||||
assert.equal(trafficConnectionMatchesFilters(first, 'vpn', 'recognized'), true);
|
||||
assert.equal(trafficConnectionMatchesFilters(first, 'direct', 'all'), false);
|
||||
});
|
||||
|
||||
test('traffic grouping keeps incompatible and unknown destinations separate', () => {
|
||||
const base = trafficConnection('base', { destination: { domain: 'example.com', ip: '203.0.113.1' } });
|
||||
const same = trafficConnection('same', { destination: { domain: 'Example.COM', ip: '203.0.113.2' } });
|
||||
@@ -343,8 +381,8 @@ test('traffic groups stay mounted and inert through exit while the same group ca
|
||||
]);
|
||||
assert.deepEqual(reconcileTrafficGroups(rows, [], true), []);
|
||||
assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/);
|
||||
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
|
||||
assert.match(feature, /trafficGroupMatches\(group, query, routeFilter, qualityFilter\)/);
|
||||
assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/);
|
||||
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
|
||||
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/);
|
||||
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
|
||||
assert.match(feature, /inert=\{exiting \|\| undefined\}/);
|
||||
@@ -379,5 +417,5 @@ test('traffic styling preserves the shared drawer geometry and minimal motion',
|
||||
assert.match(rowMotion, /translateY/);
|
||||
assert.doesNotMatch(rowMotion, /height|width|margin|padding|scale|filter/);
|
||||
}
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details \{[\s\S]*animation: none/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details,[\s\S]*\.client-traffic-device-list \{[\s\S]*animation: none/);
|
||||
});
|
||||
|
||||
@@ -40,26 +40,26 @@ const expectedImports = [
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 1153,
|
||||
cascadeEdges: 1164,
|
||||
customProperties: 115,
|
||||
declarations: 4698,
|
||||
declarations: 4752,
|
||||
important: 0,
|
||||
keyframes: 55,
|
||||
media: 22,
|
||||
rules: 1267,
|
||||
variableReferences: 1222,
|
||||
rules: 1288,
|
||||
variableReferences: 1240,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: 'b92a7365b40a48b7c1e9a026b08170c35519567132036a86049b37eebdcd5b76',
|
||||
cascadeEdges: 'e71264f527a1a7ec77ce9c382cc8b99e7363a379d258b5feb418ec591901b0aa',
|
||||
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
||||
declarations: '0e994db1d4ac16eb91d1dd0d74027fa132bad1344cf114db3641d83262116d07',
|
||||
declarations: 'bb96f5466d6717eb2e54ef620a275f510ac41a99c3dc282b221fabac5e96e2fb',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
|
||||
ruleDeclarationSequences: '6226141f0ff66b775008149ccf7d7a55e952c3f5d75961e275f0509ee352a4d6',
|
||||
selectors: '3a165769801a80e9bee5082728a58f38bb427a19ac2c764de8586a5831c9ec87',
|
||||
variableReferences: 'bdae48e482697d7cff6fdc434860cdacf19c8e3cee24b35574e461c3cff73a51',
|
||||
witnesses: '94e1f95de681072ed27f7c576e682893faf55c7489e6a35abb78a5c0d1c62cfb',
|
||||
ruleDeclarationSequences: 'f55ebc3544ddae34b1ed9aaba85fd4336d0e0722c7b986f6a42aafeb0f9abe3c',
|
||||
selectors: '5be446ae824d5d9adfe912fdefe3220c0bc1b26ad6faa2306d399a9f416a03e4',
|
||||
variableReferences: 'f8e1ec8c81d9890880a177a283b04222c34132719e0d954d3e1944a50ebcdb2e',
|
||||
witnesses: '783115d6bfe4955d195d2fa8fdc5dc602903a19fdf666f89fe4938bd47bb208a',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -212,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 1285);
|
||||
assert.equal(witnesses.length, 1311);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
@@ -409,8 +409,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||
|
||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||
assert.deepEqual(assets, ['index-Djad1gX3.css']);
|
||||
assert.deepEqual(assets, ['index--AY5M9TT.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 178147);
|
||||
assert.equal(sha256(built), 'bc1e317d3da0840d6f88a764c52385d70b97ff0bcb51d3911059a34444c14b09');
|
||||
assert.equal(built.byteLength, 180764);
|
||||
assert.equal(sha256(built), 'e36629a671854f92641bd0dfefd796336adc54e026604ef39d8389daa418beac');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user