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
+92 -27
View File
@@ -37,7 +37,7 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"description": "Сколько секунд прошло с последнего успешного обновления. До 60 секунд — штатно, 60–119 — задержка, от 120 — данные устарели.", "description": "Сколько секунд прошло с последнего успешного обновления к концу выбранного периода. До 60 секунд — штатно, 60–119 — задержка, от 120 — данные устарели.",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
@@ -106,7 +106,7 @@
"refId": "B" "refId": "B"
} }
], ],
"title": "Свежесть данных сейчас", "title": "Свежесть данных в конце выбранного периода",
"type": "stat" "type": "stat"
}, },
{ {
@@ -114,18 +114,24 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"description": "Текущая суммарная скорость всех устройств, сглаженная за последние 5 минут.", "description": "Суммарная скорость всех устройств в выбранном периоде. Окно усреднения автоматически адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз.",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
"mode": "palette-classic" "mode": "palette-classic"
}, },
"custom": { "custom": {
"axisCenteredZero": true,
"drawStyle": "line", "drawStyle": "line",
"fillOpacity": 10, "fillOpacity": 10,
"lineInterpolation": "smooth", "lineInterpolation": "smooth",
"lineWidth": 2, "lineWidth": 2,
"pointSize": 4, "pointSize": 4,
"scaleDistribution": {
"linearThreshold": 1048576,
"log": 2,
"type": "symlog"
},
"showPoints": "never", "showPoints": "never",
"spanNulls": false, "spanNulls": false,
"stacking": { "stacking": {
@@ -167,6 +173,18 @@
} }
} }
] ]
},
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "custom.transform",
"value": "negative-Y"
}
]
} }
] ]
}, },
@@ -195,20 +213,20 @@
"targets": [ "targets": [
{ {
"editorMode": "code", "editorMode": "code",
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[5m])) > 0", "expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) > 0",
"legendFormat": "Скачивание", "legendFormat": "Скачивание",
"range": true, "range": true,
"refId": "A" "refId": "A"
}, },
{ {
"editorMode": "code", "editorMode": "code",
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[5m])) > 0", "expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) > 0",
"legendFormat": "Отправка", "legendFormat": "Отправка",
"range": true, "range": true,
"refId": "B" "refId": "B"
} }
], ],
"title": "Скорость сейчас, средняя за 5 минут", "title": "Скорость за выбранный период",
"type": "timeseries" "type": "timeseries"
}, },
{ {
@@ -345,13 +363,14 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"description": "Суммарная скорость Gateway/Proxy и upload/download для каждого активного устройства из фильтра, средняя за последние 5 минут. Нулевые устройства скрыты.", "description": "Скорость скачивания и отправки каждого активного устройства в выбранном периоде. Окно усреднения адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз. Нулевые series скрыты.",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
"mode": "palette-classic" "mode": "palette-classic"
}, },
"custom": { "custom": {
"axisCenteredZero": true,
"drawStyle": "line", "drawStyle": "line",
"fillOpacity": 6, "fillOpacity": 6,
"lineInterpolation": "smooth", "lineInterpolation": "smooth",
@@ -373,7 +392,20 @@
"noValue": "Нет активного трафика", "noValue": "Нет активного трафика",
"unit": "Bps" "unit": "Bps"
}, },
"overrides": [] "overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "custom.transform",
"value": "negative-Y"
}
]
}
]
}, },
"gridPos": { "gridPos": {
"h": 9, "h": 9,
@@ -401,13 +433,20 @@
"targets": [ "targets": [
{ {
"editorMode": "code", "editorMode": "code",
"expr": "(sum by (device_id) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[5m])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)", "expr": "(sum by (device_id) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)",
"legendFormat": "{{name}} · {{ip}}", "legendFormat": "{{name}} · {{ip}} · скачивание",
"range": true, "range": true,
"refId": "A" "refId": "A"
},
{
"editorMode": "code",
"expr": "(sum by (device_id) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)",
"legendFormat": "{{name}} · {{ip}} · отправка",
"range": true,
"refId": "B"
} }
], ],
"title": "Скорость по устройствам сейчас", "title": "Скорость по устройствам",
"type": "timeseries" "type": "timeseries"
}, },
{ {
@@ -415,18 +454,24 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"description": "Скорость выбранного scope по Gateway/Proxy и upload/download, средняя за последние 5 минут. При выборе «Все устройства» остаётся максимум четыре суммарные series; нулевые series скрыты.", "description": "Скорость выбранного scope по Gateway/Proxy и направлениям в выбранном периоде. Окно усреднения адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз. Нулевые series скрыты.",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
"mode": "palette-classic" "mode": "palette-classic"
}, },
"custom": { "custom": {
"axisCenteredZero": true,
"drawStyle": "line", "drawStyle": "line",
"fillOpacity": 8, "fillOpacity": 8,
"lineInterpolation": "smooth", "lineInterpolation": "smooth",
"lineWidth": 2, "lineWidth": 2,
"pointSize": 4, "pointSize": 4,
"scaleDistribution": {
"linearThreshold": 1048576,
"log": 2,
"type": "symlog"
},
"showPoints": "never", "showPoints": "never",
"spanNulls": false, "spanNulls": false,
"stacking": { "stacking": {
@@ -438,7 +483,20 @@
"noValue": "Нет данных за период", "noValue": "Нет данных за период",
"unit": "Bps" "unit": "Bps"
}, },
"overrides": [] "overrides": [
{
"matcher": {
"id": "byFrameRefID",
"options": "B"
},
"properties": [
{
"id": "custom.transform",
"value": "negative-Y"
}
]
}
]
}, },
"gridPos": { "gridPos": {
"h": 9, "h": 9,
@@ -466,13 +524,20 @@
"targets": [ "targets": [
{ {
"editorMode": "code", "editorMode": "code",
"expr": "sum by (source, direction) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[5m])) > 0", "expr": "sum by (source) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) > 0",
"legendFormat": "{{source}} · {{direction}}", "legendFormat": "{{source}} · скачивание",
"range": true, "range": true,
"refId": "A" "refId": "A"
},
{
"editorMode": "code",
"expr": "sum by (source) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) > 0",
"legendFormat": "{{source}} · отправка",
"range": true,
"refId": "B"
} }
], ],
"title": "Скорость по источникам и направлениям сейчас", "title": "Скорость по источникам и направлениям",
"type": "timeseries" "type": "timeseries"
}, },
{ {
@@ -480,7 +545,7 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"description": "Накопленные счётчики выбранного scope за всё время хранения Harbor. При «Все устройства» счётчики суммируются; нулевые series скрыты.", "description": "Трафик выбранного scope в текущем диапазоне времени. При «Все устройства» значения суммируются; нулевые series скрыты.",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
@@ -520,14 +585,14 @@
"targets": [ "targets": [
{ {
"editorMode": "code", "editorMode": "code",
"expr": "sum by (source, direction) (harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}) > 0", "expr": "sum by (source, direction) (increase(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0",
"instant": true, "instant": true,
"legendFormat": "{{source}} · {{direction}}", "legendFormat": "{{source}} · {{direction}}",
"range": false, "range": false,
"refId": "A" "refId": "A"
} }
], ],
"title": "Накоплено по источникам и направлениям", "title": "Трафик по источникам и направлениям за выбранный период",
"type": "bargauge" "type": "bargauge"
}, },
{ {
@@ -700,7 +765,7 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"description": "Накопленный объём всех счётчиков Harbor за всё время хранения.", "description": "Объём всех счётчиков Harbor в выбранном диапазоне времени.",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
@@ -737,14 +802,14 @@
"targets": [ "targets": [
{ {
"editorMode": "code", "editorMode": "code",
"expr": "sum(harbor_traffic_bytes_total)", "expr": "sum(increase(harbor_traffic_bytes_total[$__range]))",
"instant": true, "instant": true,
"legendFormat": "Учтено Harbor", "legendFormat": "Учтено Harbor",
"range": false, "range": false,
"refId": "A" "refId": "A"
} }
], ],
"title": "Накоплено: весь Harbor", "title": "Весь Harbor за выбранный период",
"type": "stat" "type": "stat"
}, },
{ {
@@ -752,7 +817,7 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"description": "Накопленные счётчики за всё время хранения отдельно для Gateway и Proxy.", "description": "Объём трафика Gateway и Proxy в выбранном диапазоне времени.",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
@@ -823,14 +888,14 @@
"targets": [ "targets": [
{ {
"editorMode": "code", "editorMode": "code",
"expr": "sum by (source) (harbor_traffic_bytes_total) > 0", "expr": "sum by (source) (increase(harbor_traffic_bytes_total[$__range])) > 0",
"instant": true, "instant": true,
"legendFormat": "{{source}}", "legendFormat": "{{source}}",
"range": false, "range": false,
"refId": "A" "refId": "A"
} }
], ],
"title": "Накоплено: источники", "title": "Источники за выбранный период",
"type": "bargauge" "type": "bargauge"
} }
], ],
@@ -866,7 +931,7 @@
"query": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))", "query": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))",
"refId": "PrometheusVariableQueryEditor-VariableQuery" "refId": "PrometheusVariableQueryEditor-VariableQuery"
}, },
"refresh": 1, "refresh": 2,
"regex": "/display=\"(?<text>[^\"]+)\"|device_id=\"(?<value>[^\"]+)\"/g", "regex": "/display=\"(?<text>[^\"]+)\"|device_id=\"(?<value>[^\"]+)\"/g",
"skipUrlSync": false, "skipUrlSync": false,
"sort": 1, "sort": 1,
@@ -891,6 +956,6 @@
"timezone": "browser", "timezone": "browser",
"title": "Harbor Gateway: трафик", "title": "Harbor Gateway: трафик",
"uid": "harbor-gateway-traffic", "uid": "harbor-gateway-traffic",
"version": 6, "version": 7,
"weekStart": "monday" "weekStart": "monday"
} }
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.21.5', macClient: '0.21.6',
gatewayClient: '0.22.0', gatewayClient: '0.22.1',
gatewayBackend: '0.22.5', gatewayBackend: '0.22.5',
}); });
+8 -8
View File
@@ -537,14 +537,6 @@ export function ClientOverviewPage({
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true"> <div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
<span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span> <span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span>
</div> </div>
<HarborBrand
isGateway={isGateway}
connected={connected}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню"> {(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{isGateway && <SubscriptionToggle {isGateway && <SubscriptionToggle
feature={subscriptionFeature} feature={subscriptionFeature}
@@ -609,6 +601,14 @@ export function ClientOverviewPage({
proxyPort={state?.clientRuntime?.proxyPort} proxyPort={state?.clientRuntime?.proxyPort}
now={now} now={now}
blocked={connectionBlocked} blocked={connectionBlocked}
brandSlot={<HarborBrand
isGateway={isGateway}
connected={connected}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>}
copyFeedback={copyFeedback} copyFeedback={copyFeedback}
onCopyProxy={copyProxy} onCopyProxy={copyProxy}
onApply={onApply} onApply={onApply}
@@ -34,6 +34,7 @@ interface ConnectionPanelProps {
proxyPort?: number; proxyPort?: number;
now: number; now: number;
blocked: boolean; blocked: boolean;
brandSlot?: ReactNode;
copyFeedback?: Partial<Record<CopyKind, CopyFeedback>>; copyFeedback?: Partial<Record<CopyKind, CopyFeedback>>;
routingSlot?: ReactNode; routingSlot?: ReactNode;
serverSlot?: ReactNode; serverSlot?: ReactNode;
@@ -74,6 +75,7 @@ export function ConnectionPanel({
proxyPort, proxyPort,
now, now,
blocked, blocked,
brandSlot,
copyFeedback, copyFeedback,
routingSlot, routingSlot,
serverSlot, serverSlot,
@@ -161,18 +163,19 @@ export function ConnectionPanel({
</button>; </button>;
return <> return <>
{visible && <section className={`client-power-section${isGateway ? ' is-gateway' : ''}`} aria-labelledby="connection-title"> {visible ? <section className={`client-power-section${isGateway ? ' is-gateway' : ''}`} aria-labelledby="connection-title">
{isGateway ? <span <div
className="client-power-control client-tooltip-anchor" className={`client-power-control${isGateway ? ' client-tooltip-anchor' : ''}`}
tabIndex={powerUnavailable ? 0 : undefined} tabIndex={powerUnavailable ? 0 : undefined}
aria-label={powerUnavailable ? 'VPN недоступен' : undefined} aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined} aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
> >
{brandSlot}
{powerButton} {powerButton}
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip"> {powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip">
Сначала добавьте подписку и выберите сервер Сначала добавьте подписку и выберите сервер
</span>} </span>}
</span> : powerButton} </div>
<div className="client-state-detail"> <div className="client-state-detail">
{connected ? ( {connected ? (
<button <button
@@ -266,7 +269,7 @@ export function ConnectionPanel({
</div> </div>
</section> </section>
{statusSlot} {statusSlot}
</section>} </section> : brandSlot}
<ConfirmationDialog <ConfirmationDialog
open={confirmingStop} open={confirmingStop}
+20 -6
View File
@@ -29,6 +29,7 @@
} }
.client-power-control { .client-power-control {
position: relative;
width: 96px; width: 96px;
height: 96px; height: 96px;
display: grid; display: grid;
@@ -38,18 +39,31 @@
.client-power-section.is-gateway .client-power-control, .client-power-section.is-gateway .client-power-control,
.client-power-section.is-gateway .client-power { .client-power-section.is-gateway .client-power {
width: 68px; width: 84px;
height: 68px; height: 84px;
} }
.client-power-section.is-gateway .client-power svg { .client-power-section.is-gateway .client-power svg {
width: 30px; width: 36px;
height: 30px; height: 36px;
} }
.client-power-section.is-gateway .client-power::before { .client-power-section.is-gateway .client-power::before {
width: 44px; width: 52px;
height: 44px; height: 52px;
}
.client-power-control > .client-tooltip {
top: calc(100% + 12px);
right: auto;
bottom: auto;
left: 50%;
transform: translate(-50%, -2px);
}
.client-power-control.client-tooltip-anchor:hover > .client-tooltip,
.client-power-control.client-tooltip-anchor:focus-visible > .client-tooltip {
transform: translate(-50%, 0);
} }
.client-power-control:focus-visible { .client-power-control:focus-visible {
+19 -11
View File
@@ -152,6 +152,17 @@
pointer-events: none; pointer-events: none;
} }
.client-power-control > .harbor-brand {
position: absolute;
top: auto;
right: auto;
bottom: calc(100% + 44px);
left: 50%;
width: max-content;
transform: translateX(-50%) scale(3);
transform-origin: center bottom;
}
.harbor-brand.is-switchable { .harbor-brand.is-switchable {
pointer-events: auto; pointer-events: auto;
} }
@@ -206,13 +217,6 @@
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 44%, transparent)); filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 44%, transparent));
} }
.harbor-brand.is-connected .harbor-brand-content > svg,
.harbor-brand.is-connected .harbor-brand-name > strong,
.harbor-brand.is-connected .harbor-brand-name > em,
.harbor-brand.is-connected .harbor-mode-stack {
transform: translateY(-4px);
}
.harbor-brand.is-connected .harbor-brand-name > strong, .harbor-brand.is-connected .harbor-brand-name > strong,
.harbor-brand.is-connected .harbor-brand-name > em, .harbor-brand.is-connected .harbor-brand-name > em,
.harbor-brand.is-connected .harbor-mode-stack { .harbor-brand.is-connected .harbor-mode-stack {
@@ -397,10 +401,6 @@
animation: harbor-mode-arrows-discovered 260ms 420ms cubic-bezier(0.16, 1, 0.3, 1) both; animation: harbor-mode-arrows-discovered 260ms 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
} }
.harbor-brand.is-connected .harbor-mode-swap {
transform: translateY(calc(-50% - 4px));
}
@keyframes harbor-mode-arrows-discovered { @keyframes harbor-mode-arrows-discovered {
from { from {
opacity: 0; opacity: 0;
@@ -662,6 +662,10 @@
transform: translateX(-50%) scale(1.55); transform: translateX(-50%) scale(1.55);
} }
.client-power-control > .harbor-brand {
transform: translateX(-50%) scale(1.55);
}
.client-inline-error.is-connection, .client-inline-error.is-connection,
.client-inline-error.is-subscription { .client-inline-error.is-subscription {
position: static; position: static;
@@ -719,6 +723,10 @@
transform: translateX(-50%) scale(1.35); transform: translateX(-50%) scale(1.35);
} }
.client-power-control > .harbor-brand {
transform: translateX(-50%) scale(1.35);
}
.client-secondary-menu { .client-secondary-menu {
right: 8px; right: 8px;
} }
+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.equal((page.match(/<ConnectionPanel/g) || []).length, 1);
assert.match(page, /<main[\s\S]*<ConnectionPanel[\s\S]*<GatewayTrafficSummary/); 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.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, /<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'); 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, /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(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, /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, /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]*\{isGateway \? <span[\s\S]*<\/span> : powerButton\}/); 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.match(overview, /isGateway && <DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/); assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
}); });
+32 -9
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 topDevices = dashboard.panels.find(({ id }) => id === 5);
const sourceSpeed = dashboard.panels.find(({ id }) => id === 6); const sourceSpeed = dashboard.panels.find(({ id }) => id === 6);
const deviceSpeed = dashboard.panels.find(({ id }) => id === 14); 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 services = dashboard.panels.find(({ id }) => id === 8);
const domains = dashboard.panels.find(({ id }) => id === 9); 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.title, 'Harbor Gateway: трафик');
assert.equal(dashboard.refresh, '30s'); assert.equal(dashboard.refresh, '30s');
assert.equal(dashboard.version, 7);
assert.deepEqual(titles.filter((title) => ['Обзор', 'Устройства', 'Сервисы и домены', 'Техническая детализация'].includes(title)), [ 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('Топ-10 устройств за выбранный период'));
assert.ok(titles.includes('Накоплено по источникам и направлениям')); assert.ok(titles.includes('Трафик по источникам и направлениям за выбранный период'));
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_traffic_bytes_total')));
assert.ok(expressions.some((expression) => expression.includes('harbor_device_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]'))); 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.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.deepEqual(freshness.fieldConfig.defaults.thresholds.steps.map(({ value }) => value), [null, 60, 120]);
assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных'); assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных');
assert.equal(freshness.title, 'Свежесть данных в конце выбранного периода');
assert.equal(deviceVariable.name, 'device_id'); assert.equal(deviceVariable.name, 'device_id');
assert.equal(deviceVariable.multi, false); assert.equal(deviceVariable.multi, false);
assert.equal(deviceVariable.includeAll, true); assert.equal(deviceVariable.includeAll, true);
assert.equal(deviceVariable.allValue, '.*'); assert.equal(deviceVariable.allValue, '.*');
assert.equal(deviceVariable.current.value, '$__all'); assert.equal(deviceVariable.current.value, '$__all');
assert.equal(deviceVariable.refresh, 2);
assert.equal(dashboard.templating.list.length, 1); assert.equal(dashboard.templating.list.length, 1);
assert.match(deviceVariable.query.query, /query_result\(label_join\(/); assert.match(deviceVariable.query.query, /query_result\(label_join\(/);
assert.match(deviceVariable.regex, /<text>/); assert.match(deviceVariable.regex, /<text>/);
assert.match(deviceVariable.regex, /<value>/); assert.match(deviceVariable.regex, /<value>/);
assert.ok(globalSpeed.targets.every(({ expr }) => expr.includes('device_id=~"$device_id"') && expr.endsWith('> 0'))); for (const panel of speedPanels) {
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.equal(panel.fieldConfig.defaults.custom.axisCenteredZero, true);
assert.match(deviceSpeed.targets[0].legendFormat, /\{\{name\}\} · \{\{ip\}\}/); assert.deepEqual(panel.fieldConfig.defaults.custom.scaleDistribution, {
assert.deepEqual(deviceSpeed.fieldConfig.defaults.custom.scaleDistribution, {
linearThreshold: 1048576, linearThreshold: 1048576,
log: 2, log: 2,
type: 'symlog', type: 'symlog',
}); });
assert.match(sourceSpeed.targets[0].expr, /device_id=~"\$device_id"[\s\S]*> 0$/); 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.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.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.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\)$/); 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(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-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(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(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.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/); 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, /max-height:\s*none/);
assert.match(responsive, /overflow-y:\s*visible/); assert.match(responsive, /overflow-y:\s*visible/);
assert.match(responsive, /\.harbor-brand,[\s\S]*position:\s*absolute/); 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(responsive, /\.client-inline-error\.is-connection,[\s\S]*position:\s*static/);
assert.match(rule('.client-duration-toggle'), /width:\s*min\(290px, 100%\)/); 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-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-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(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-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/);
assert.match(styles, /\.client-local-rules\s*\{[\s\S]*width:\s*min\(480px, 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 sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = { const acceptedLedger = {
counts: { counts: {
cascadeEdges: 780, cascadeEdges: 797,
customProperties: 31, customProperties: 31,
declarations: 2885, declarations: 2900,
important: 0, important: 0,
keyframes: 48, keyframes: 48,
media: 10, media: 10,
rules: 861, rules: 864,
variableReferences: 339, variableReferences: 339,
}, },
hashes: { hashes: {
cascadeEdges: '7d47b1c26e06748e4532ce9ba7229e9a9bab4058b9c615129ce668aa7e906714', cascadeEdges: '55bd3926b5da1a9ccc6dee5f3eb5cf2c111183e0410edcc085545e5a35c0b229',
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545', customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
declarations: 'ea140d4e00166daba7dc92fabb2ef63afd2ed3cb9d6c0dadeaf346bbf1eddc32', declarations: '67441a07783c586844638db628ab401bbf52bdb1eaf5dbec20e65bebff6faac1',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47', duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
keyframes: '853c54c05759d9db27bea891254913e1651b1f601059ad9e3e2baa2c55ef1b2b', keyframes: '853c54c05759d9db27bea891254913e1651b1f601059ad9e3e2baa2c55ef1b2b',
ruleDeclarationSequences: '1a231e3068345d08f1670b64e5b2db91abbdb7083f164d3e96f073d4ae561bdb', ruleDeclarationSequences: '75f73d80e2c00e0575974b3cfdef3d27cd509e598f09250223e813f107e0169e',
selectors: '824d578e36c3ae25a39bc686494635da353ea45996633eed01f93132dfb4dabd', selectors: '7e87d3d05fa525ad2414fbd58bd128a1b30be5c4b01a27151c03c9c2692d6384',
variableReferences: 'da4298e47c6249c3cfa12168d3670b0ec9c92c0fca7033ba968ea70598753a65', 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); assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); 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])); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 107497); assert.equal(built.byteLength, 107832);
assert.equal(sha256(built), '40412db73b9c6cd2f2f49f4a2b6cdf2130253a12f4d0d0e4a37277017933421c'); assert.equal(sha256(built), 'fee93089544bccf45203d53329d54ae10092e3bd93f0cd9b83dccda2202406ef');
}); });