Improve gateway traffic dashboard and connection branding
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-09 14:37:11 +03:00
parent 3204ecf4a5
commit d349ca5e29
11 changed files with 208 additions and 86 deletions
+4 -1
View File
@@ -15,8 +15,11 @@ test('connection feature is the sole always-mounted power panel owner', () => {
assert.equal((page.match(/<ConnectionPanel/g) || []).length, 1);
assert.match(page, /<main[\s\S]*<ConnectionPanel[\s\S]*<GatewayTrafficSummary/);
assert.doesNotMatch(page, /client-power-section|const powerButton|function toggleConnection|confirmingStop|id="stop-connection"|DURATION_MODE_STORAGE_KEY/);
assert.match(panel, /\{visible && <section className=\{`client-power-section\$\{isGateway \? ' is-gateway' : ''\}`\}/);
assert.match(panel, /\{visible \? <section className=\{`client-power-section\$\{isGateway \? ' is-gateway' : ''\}`\}/);
assert.match(panel, /<ConfirmationDialog[\s\S]*open=\{confirmingStop\}/);
assert.match(panel, /<div[\s\S]*client-power-control[\s\S]*\{brandSlot\}[\s\S]*\{powerButton\}[\s\S]*<\/div>/);
assert.match(page, /brandSlot=\{<HarborBrand[\s\S]*onSetGatewayAuto=\{onSetGatewayAuto\}[\s\S]*\/>\}/);
assert.match(panel, /<\/section> : brandSlot/);
assert.ok(panel.indexOf('<ConfirmationDialog') > panel.indexOf('{visible && <section'), 'dialog remains mounted outside the visible section');
});
+2 -2
View File
@@ -201,8 +201,8 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
assert.match(subscription, /const confirmingDeleteRef = useRef\(confirmingDelete\)[\s\S]*if \(confirmingDeleteRef\.current\) return/);
assert.match(subscription, /requestDelete: \(\) => setConfirmingDelete\(true\)[\s\S]*open=\{feature\.confirmingDelete\}[\s\S]*onCancel=\{feature\.cancelDelete\}/);
assert.match(connection, /aria-label=\{isGateway[\s\S]*Остановить Harbor Connect[\s\S]*Запустить Harbor Connect/);
assert.match(connection, /className="client-power-control client-tooltip-anchor"[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Сначала добавьте подписку и выберите сервер/);
assert.match(connection, /const powerButton = <button[\s\S]*className="client-power"[\s\S]*\{isGateway \? <span[\s\S]*<\/span> : powerButton\}/);
assert.match(connection, /className=\{`client-power-control\$\{isGateway \? ' client-tooltip-anchor' : ''\}`\}[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Сначала добавьте подписку и выберите сервер/);
assert.match(connection, /const powerButton = <button[\s\S]*className="client-power"[\s\S]*client-power-control[\s\S]*\{brandSlot\}[\s\S]*\{powerButton\}/);
assert.match(overview, /isGateway && <DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
});
+35 -12
View File
@@ -38,24 +38,31 @@ test('Grafana dashboard uses one all-or-one device scope and shows active device
const topDevices = dashboard.panels.find(({ id }) => id === 5);
const sourceSpeed = dashboard.panels.find(({ id }) => id === 6);
const deviceSpeed = dashboard.panels.find(({ id }) => id === 14);
const sourceTraffic = dashboard.panels.find(({ id }) => id === 7);
const services = dashboard.panels.find(({ id }) => id === 8);
const domains = dashboard.panels.find(({ id }) => id === 9);
const totalTraffic = dashboard.panels.find(({ id }) => id === 1);
const trafficBySource = dashboard.panels.find(({ id }) => id === 4);
const speedPanels = [globalSpeed, deviceSpeed, sourceSpeed];
assert.equal(dashboard.title, 'Harbor Gateway: трафик');
assert.equal(dashboard.refresh, '30s');
assert.equal(dashboard.version, 7);
assert.deepEqual(titles.filter((title) => ['Обзор', 'Устройства', 'Сервисы и домены', 'Техническая детализация'].includes(title)), [
'Обзор',
'Устройства',
'Сервисы и домены',
'Техническая детализация',
]);
assert.ok(titles.includes('Скорость сейчас, средняя за 5 минут'));
assert.ok(titles.includes('Скорость по устройствам сейчас'));
assert.ok(titles.includes('Скорость по источникам и направлениям сейчас'));
assert.ok(titles.includes('Скорость за выбранный период'));
assert.ok(titles.includes('Скорость по устройствам'));
assert.ok(titles.includes('Скорость по источникам и направлениям'));
assert.ok(titles.includes('Топ-10 устройств за выбранный период'));
assert.ok(titles.includes('Накоплено по источникам и направлениям'));
assert.ok(titles.includes('Трафик по источникам и направлениям за выбранный период'));
assert.ok(titles.includes('Сервисы за выбранный период'));
assert.ok(titles.includes('Домены за выбранный период'));
assert.ok(titles.includes('Весь Harbor за выбранный период'));
assert.ok(titles.includes('Источники за выбранный период'));
assert.ok(expressions.some((expression) => expression.includes('harbor_traffic_bytes_total')));
assert.ok(expressions.some((expression) => expression.includes('harbor_device_traffic_bytes_total')));
assert.ok(expressions.some((expression) => expression.includes('harbor_device_domain_traffic_bytes_total{device_id=~"$device_id"}[$__range]')));
@@ -66,24 +73,40 @@ test('Grafana dashboard uses one all-or-one device scope and shows active device
assert.doesNotMatch(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /domain_device_id/);
assert.deepEqual(freshness.fieldConfig.defaults.thresholds.steps.map(({ value }) => value), [null, 60, 120]);
assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных');
assert.equal(freshness.title, 'Свежесть данных в конце выбранного периода');
assert.equal(deviceVariable.name, 'device_id');
assert.equal(deviceVariable.multi, false);
assert.equal(deviceVariable.includeAll, true);
assert.equal(deviceVariable.allValue, '.*');
assert.equal(deviceVariable.current.value, '$__all');
assert.equal(deviceVariable.refresh, 2);
assert.equal(dashboard.templating.list.length, 1);
assert.match(deviceVariable.query.query, /query_result\(label_join\(/);
assert.match(deviceVariable.regex, /<text>/);
assert.match(deviceVariable.regex, /<value>/);
for (const panel of speedPanels) {
assert.equal(panel.fieldConfig.defaults.custom.axisCenteredZero, true);
assert.deepEqual(panel.fieldConfig.defaults.custom.scaleDistribution, {
linearThreshold: 1048576,
log: 2,
type: 'symlog',
});
assert.deepEqual(panel.targets.map(({ refId }) => refId), ['A', 'B']);
assert.ok(panel.targets.every(({ expr }) => expr.includes('[$__rate_interval]') && !expr.includes('[5m]')));
assert.ok(panel.fieldConfig.overrides.some((override) => (
override.matcher.id === 'byFrameRefID'
&& override.matcher.options === 'B'
&& override.properties.some(({ id, value }) => id === 'custom.transform' && value === 'negative-Y')
)));
}
assert.ok(globalSpeed.targets.every(({ expr }) => expr.includes('device_id=~"$device_id"') && expr.endsWith('> 0')));
assert.match(deviceSpeed.targets[0].expr, /sum by \(device_id\)[\s\S]*device_id=~"\$device_id"[\s\S]*> 0[\s\S]*group_left \(name, ip\)/);
assert.match(deviceSpeed.targets[0].legendFormat, /\{\{name\}\} · \{\{ip\}\}/);
assert.deepEqual(deviceSpeed.fieldConfig.defaults.custom.scaleDistribution, {
linearThreshold: 1048576,
log: 2,
type: 'symlog',
});
assert.match(sourceSpeed.targets[0].expr, /device_id=~"\$device_id"[\s\S]*> 0$/);
assert.ok(deviceSpeed.targets.every(({ expr }) => /sum by \(device_id\)[\s\S]*device_id=~"\$device_id"[\s\S]*> 0[\s\S]*group_left \(name, ip\)/.test(expr)));
assert.ok(deviceSpeed.targets.every(({ legendFormat }) => /\{\{name\}\} · \{\{ip\}\}/.test(legendFormat)));
assert.ok(sourceSpeed.targets.every(({ expr }) => /device_id=~"\$device_id"[\s\S]*> 0$/.test(expr)));
assert.equal(totalTraffic.targets[0].expr, 'sum(increase(harbor_traffic_bytes_total[$__range]))');
assert.equal(trafficBySource.targets[0].expr, 'sum by (source) (increase(harbor_traffic_bytes_total[$__range])) > 0');
assert.match(sourceTraffic.targets[0].expr, /increase\(harbor_device_traffic_bytes_total\{device_id=~"\$device_id"\}\[\$__range\]\)/);
assert.ok(dashboard.panels.every(({ title = '', description = '' }) => !/Накоплено|за всё время/.test(`${title} ${description}`)));
assert.match(services.targets[0].expr, /device_id=~"\$device_id"[\s\S]*> 0\)$/);
assert.deepEqual(domains.transformations[0].options.indexByName, { service: 0, domain: 1, Value: 2 });
assert.match(domains.targets[0].expr, /^topk\(15,[\s\S]*device_id=~"\$device_id"[\s\S]*> 0\)$/);
+7 -1
View File
@@ -41,7 +41,11 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
assert.match(styles, /\.client-panel\.has-subscription \.client-form-content \{[\s\S]*padding-top:\s*var\(--client-power-top\)/);
assert.match(rule('.client-panel.is-gateway-home'), /min-height:\s*max\(620px, calc\(100dvh - 80px\)\)[\s\S]*grid-template-rows:\s*minmax\(260px, 1fr\) auto[\s\S]*row-gap:\s*36px/);
assert.match(rule('.client-gateway-summary'), /grid-column:\s*1 \/ -1[\s\S]*grid-row:\s*2[\s\S]*justify-self:\s*center[\s\S]*width:\s*min\(860px, calc\(100% - 48px\)\)/);
assert.match(styles, /\.client-power-section\.is-gateway \.client-power-control,[\s\S]*width:\s*68px;[\s\S]*height:\s*68px/);
assert.match(styles, /\.client-power-section\.is-gateway \.client-power-control,[\s\S]*width:\s*84px;[\s\S]*height:\s*84px/);
assert.match(rule('.client-power-control'), /position:\s*relative/);
assert.match(rule('.client-power-control > .harbor-brand'), /position:\s*absolute[\s\S]*bottom:\s*calc\(100% \+ 44px\)[\s\S]*left:\s*50%[\s\S]*transform:\s*translateX\(-50%\) scale\(3\)[\s\S]*transform-origin:\s*center bottom/);
assert.match(rule('.client-power-control > .client-tooltip'), /top:\s*calc\(100% \+ 12px\)[\s\S]*left:\s*50%[\s\S]*translate\(-50%, -2px\)/);
assert.doesNotMatch(styles, /\.harbor-brand\.is-connected[^}]*transform|translateY\(calc\(-50% - 4px\)\)/);
assert.match(rule('.client-gateway-traffic-chart'), /min-height:\s*180px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-gateway-traffic-chart,[\s\S]*min-height:\s*130px;[\s\S]*height:\s*130px/);
assert.doesNotMatch(rule('.client-gateway-summary'), /background|border|box-shadow/);
@@ -92,11 +96,13 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
assert.match(responsive, /max-height:\s*none/);
assert.match(responsive, /overflow-y:\s*visible/);
assert.match(responsive, /\.harbor-brand,[\s\S]*position:\s*absolute/);
assert.match(responsive, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.55\)/);
assert.match(responsive, /\.client-inline-error\.is-connection,[\s\S]*position:\s*static/);
assert.match(rule('.client-duration-toggle'), /width:\s*min\(290px, 100%\)/);
assert.match(mobile, /\.client-server-toolbar \{[\s\S]*grid-template-rows:\s*44px 44px/);
assert.match(mobile, /\.client-form-content \{[\s\S]*gap:\s*24px/);
assert.match(mobile, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.35\)/);
assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/);
assert.match(styles, /\.client-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/);
assert.match(styles, /\.client-local-rules\s*\{[\s\S]*width:\s*min\(480px, 100vw\)/);
+11 -11
View File
@@ -36,26 +36,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 780,
cascadeEdges: 797,
customProperties: 31,
declarations: 2885,
declarations: 2900,
important: 0,
keyframes: 48,
media: 10,
rules: 861,
rules: 864,
variableReferences: 339,
},
hashes: {
cascadeEdges: '7d47b1c26e06748e4532ce9ba7229e9a9bab4058b9c615129ce668aa7e906714',
cascadeEdges: '55bd3926b5da1a9ccc6dee5f3eb5cf2c111183e0410edcc085545e5a35c0b229',
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
declarations: 'ea140d4e00166daba7dc92fabb2ef63afd2ed3cb9d6c0dadeaf346bbf1eddc32',
declarations: '67441a07783c586844638db628ab401bbf52bdb1eaf5dbec20e65bebff6faac1',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
keyframes: '853c54c05759d9db27bea891254913e1651b1f601059ad9e3e2baa2c55ef1b2b',
ruleDeclarationSequences: '1a231e3068345d08f1670b64e5b2db91abbdb7083f164d3e96f073d4ae561bdb',
selectors: '824d578e36c3ae25a39bc686494635da353ea45996633eed01f93132dfb4dabd',
ruleDeclarationSequences: '75f73d80e2c00e0575974b3cfdef3d27cd509e598f09250223e813f107e0169e',
selectors: '7e87d3d05fa525ad2414fbd58bd128a1b30be5c4b01a27151c03c9c2692d6384',
variableReferences: 'da4298e47c6249c3cfa12168d3670b0ec9c92c0fca7033ba968ea70598753a65',
witnesses: '6d498098d34d0b9fe399e457ece265456d99b4351a46a534e1b4fe5e4620e90f',
witnesses: '855149a346b32cf84595b8aabf3acef047d7366ee87aea79ab66165042e3a001',
},
};
@@ -293,8 +293,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-ZmHjygJv.css']);
assert.deepEqual(assets, ['index-CKZGCKzI.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 107497);
assert.equal(sha256(built), '40412db73b9c6cd2f2f49f4a2b6cdf2130253a12f4d0d0e4a37277017933421c');
assert.equal(built.byteLength, 107832);
assert.equal(sha256(built), 'fee93089544bccf45203d53329d54ae10092e3bd93f0cd9b83dccda2202406ef');
});