Add device identity tooltips and MAC validation
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-11 11:45:11 +03:00
parent 7e4da4bdcf
commit 501c498edf
8 changed files with 43 additions and 25 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.24.0', macClient: '0.24.1',
gatewayClient: '0.25.0', gatewayClient: '0.25.1',
gatewayBackend: '0.25.0', gatewayBackend: '0.25.0',
}); });
+7 -2
View File
@@ -387,6 +387,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const groupStart = group !== previousGroup; const groupStart = group !== previousGroup;
const hasName = Boolean(device.alias || device.hostname); const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство'; const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const identityTooltipId = `device-identity-${device.id}`;
const editing = editingId === device.id; const editing = editingId === device.id;
const saving = savingId === device.id; const saving = savingId === device.id;
const seen = formatLastSeen(device.lastSeenAt); const seen = formatLastSeen(device.lastSeenAt);
@@ -452,7 +453,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</span>} </span>}
<div className="client-device-main"> <div className="client-device-main">
<h4 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}> <h4 className={`client-device-name-heading client-tooltip-anchor${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
{editing ? ( {editing ? (
<input <input
className="client-device-alias-input" className="client-device-alias-input"
@@ -461,6 +462,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
maxLength={64} maxLength={64}
autoFocus autoFocus
aria-label="Название устройства" aria-label="Название устройства"
aria-describedby={identityTooltipId}
aria-busy={saving} aria-busy={saving}
disabled={saving} disabled={saving}
onChange={(event) => setAlias(event.target.value)} onChange={(event) => setAlias(event.target.value)}
@@ -470,9 +472,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
}} }}
/> />
) : hasName && <button ) : hasName && <button
className="client-device-alias-trigger" className={`client-device-alias-trigger${device.alias ? ' is-custom-name' : ''}`}
type="button" type="button"
aria-label={`Изменить название ${title}`} aria-label={`Изменить название ${title}`}
aria-describedby={identityTooltipId}
onClick={() => startEditing(device)} onClick={() => startEditing(device)}
>{title}</button>} >{title}</button>}
{(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>} {(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
@@ -480,8 +483,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
className={`client-device-ip${copied ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`} className={`client-device-ip${copied ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button" type="button"
aria-label={`Скопировать IP ${device.ip} устройства ${title}`} aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
aria-describedby={identityTooltipId}
onClick={() => copyDeviceIp(device)} onClick={() => copyDeviceIp(device)}
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>} >{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
<Tooltip id={identityTooltipId}>Hostname: {device.hostname || '—'} · MAC: {device.mac}</Tooltip>
</h4> </h4>
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor"> {!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
<button <button
@@ -17,6 +17,7 @@ export interface Device extends Record<string, unknown> {
id: string; id: string;
alias: string | null; alias: string | null;
hostname: string | null; hostname: string | null;
mac: string;
ip: string | null; ip: string | null;
lastSeenAt: string | null; lastSeenAt: string | null;
status: DeviceStatus; status: DeviceStatus;
@@ -113,6 +114,8 @@ function validDevice(value: unknown): value is Device {
&& /^dev_[a-f0-9]{16}$/.test(value.id) && /^dev_[a-f0-9]{16}$/.test(value.id)
&& nullableString(value.alias) && nullableString(value.alias)
&& nullableString(value.hostname) && nullableString(value.hostname)
&& typeof value.mac === 'string'
&& /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(value.mac)
&& nullableString(value.ip) && nullableString(value.ip)
&& nullableTimestamp(value.lastSeenAt) && nullableTimestamp(value.lastSeenAt)
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline') && (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
+10 -4
View File
@@ -277,7 +277,7 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
overflow: hidden; overflow: visible;
margin: 0; margin: 0;
gap: 5px; gap: 5px;
font: var(--type-item-title); font: var(--type-item-title);
@@ -314,6 +314,12 @@
cursor: text; cursor: text;
} }
.client-device-alias-trigger.is-custom-name {
font: var(--type-section-title);
letter-spacing: var(--type-section-title-tracking);
text-transform: var(--type-section-title-transform);
}
.client-device-alias-input { .client-device-alias-input {
width: var(--alias-width); width: var(--alias-width);
min-width: 1px; min-width: 1px;
@@ -324,9 +330,9 @@
outline: 0; outline: 0;
background: transparent; background: transparent;
caret-color: var(--client-accent); caret-color: var(--client-accent);
font: var(--type-item-title); font: var(--type-section-title);
letter-spacing: var(--type-item-title-tracking); letter-spacing: var(--type-section-title-tracking);
text-transform: var(--type-item-title-transform); text-transform: var(--type-section-title-transform);
animation: client-device-alias-edit-in 360ms cubic-bezier(0.16, 1, 0.3, 1); animation: client-device-alias-edit-in 360ms cubic-bezier(0.16, 1, 0.3, 1);
} }
+6 -4
View File
@@ -54,7 +54,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/); assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/);
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/); assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
assert.match(panel, /\{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">/); assert.match(panel, /\{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">/);
assert.match(panel, /client-device-name-heading\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}/); assert.match(panel, /client-device-name-heading client-tooltip-anchor\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}/);
assert.match(panel, /className="client-device-alias-input"[\s\S]*onBlur=\{\(\) => saveAlias\(device\)\}[\s\S]*event\.key === 'Enter'[\s\S]*event\.currentTarget\.blur\(\)/); assert.match(panel, /className="client-device-alias-input"[\s\S]*onBlur=\{\(\) => saveAlias\(device\)\}[\s\S]*event\.key === 'Enter'[\s\S]*event\.currentTarget\.blur\(\)/);
assert.match(panel, /aliasBaseline\.current = \{ id: device\.id, value \}/); assert.match(panel, /aliasBaseline\.current = \{ id: device\.id, value \}/);
assert.match(panel, /style=\{\{ '--alias-width': `\$\{Math\.max\(1, alias\.length\)\}ch` \} as CSSProperties\}/); assert.match(panel, /style=\{\{ '--alias-width': `\$\{Math\.max\(1, alias\.length\)\}ch` \} as CSSProperties\}/);
@@ -73,8 +73,9 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.doesNotMatch(panel, /client-text-morph-goo/); assert.doesNotMatch(panel, /client-text-morph-goo/);
assert.doesNotMatch(panel, /client-device-addresses|client-device-manufacturer|client-device-meta/); assert.doesNotMatch(panel, /client-device-addresses|client-device-manufacturer|client-device-meta/);
assert.doesNotMatch(panel, /device\.interface/); assert.doesNotMatch(panel, /device\.interface/);
assert.doesNotMatch(panel, /device\.mac/); assert.match(panel, /identityTooltipId = `device-identity-\$\{device\.id\}`/);
assert.doesNotMatch(panel, /client-device-identity|Пояснение идентификации устройства|ⓘ/); assert.match(panel, /<Tooltip id=\{identityTooltipId\}>Hostname: \{device\.hostname \|\| '—'\} · MAC: \{device\.mac\}<\/Tooltip>/);
assert.match(panel, /aria-describedby=\{identityTooltipId\}/);
assert.match(panel, /device\.confidence === 'ambiguous'/); assert.match(panel, /device\.confidence === 'ambiguous'/);
assert.match(panel, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/); assert.match(panel, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/);
assert.match(panel, /Трафик временно не обновляется/); assert.match(panel, /Трафик временно не обновляется/);
@@ -157,7 +158,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /@keyframes client-device-traffic-detail-out[\s\S]*opacity: 1[\s\S]*opacity: 0/); assert.match(styles, /@keyframes client-device-traffic-detail-out[\s\S]*opacity: 1[\s\S]*opacity: 0/);
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/); assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: var\(--type-item-title\)/); assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: var\(--type-item-title\)/);
assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*client-device-alias-edit-in 360ms/); assert.match(styles, /\.client-device-alias-trigger\.is-custom-name \{[\s\S]*font: var\(--type-section-title\)/);
assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*font: var\(--type-section-title\)[\s\S]*client-device-alias-edit-in 360ms/);
assert.match(styles, /@keyframes client-device-alias-edit-in[\s\S]*color: var\(--client-accent\)[\s\S]*filter: blur\(2px\)/); assert.match(styles, /@keyframes client-device-alias-edit-in[\s\S]*color: var\(--client-accent\)[\s\S]*filter: blur\(2px\)/);
assert.doesNotMatch(styles, /\.client-device-alias \{/); assert.doesNotMatch(styles, /\.client-device-alias \{/);
assert.match(styles, /\.client-device-ip \{[\s\S]*font: var\(--type-data\)/); assert.match(styles, /\.client-device-ip \{[\s\S]*font: var\(--type-data\)/);
@@ -46,6 +46,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
id: 'dev_0123456789abcdef', id: 'dev_0123456789abcdef',
alias: null, alias: null,
hostname: null, hostname: null,
mac: '00:11:22:33:44:55',
ip: null, ip: null,
lastSeenAt: null, lastSeenAt: null,
status: 'online', status: 'online',
@@ -96,6 +97,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
{ ...valid, traffic: undefined }, { ...valid, traffic: undefined },
{ ...valid, source: undefined }, { ...valid, source: undefined },
{ ...valid, devices: [{ ...valid.devices[0], id: 'not-a-device' }] }, { ...valid, devices: [{ ...valid.devices[0], id: 'not-a-device' }] },
{ ...valid, devices: [{ ...valid.devices[0], mac: 'not-a-mac' }] },
{ ...valid, devices: [{ ...valid.devices[0], downloadBytes: 'not-bytes' }] }, { ...valid, devices: [{ ...valid.devices[0], downloadBytes: 'not-bytes' }] },
{ ...valid, devices: [{ ...valid.devices[0], uploadBytes: '-1' }] }, { ...valid, devices: [{ ...valid.devices[0], uploadBytes: '-1' }] },
{ ...valid, devices: [{ ...valid.devices[0], proxyUploadBytes: 1 }] }, { ...valid, devices: [{ ...valid.devices[0], proxyUploadBytes: 1 }] },
+12 -12
View File
@@ -39,24 +39,24 @@ const acceptedLedger = {
counts: { counts: {
cascadeEdges: 749, cascadeEdges: 749,
customProperties: 103, customProperties: 103,
declarations: 3200, declarations: 3203,
important: 0, important: 0,
keyframes: 56, keyframes: 56,
media: 13, media: 13,
rules: 917, rules: 918,
variableReferences: 791, variableReferences: 794,
}, },
hashes: { hashes: {
cascadeEdges: 'a47dbf5212045c865de7a41d420e78df8b992a6ec1b06fb18a8d459cd480f0ae', cascadeEdges: 'a47dbf5212045c865de7a41d420e78df8b992a6ec1b06fb18a8d459cd480f0ae',
customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4', customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4',
declarations: '198ccda82654a42a5ba3f452fed7a6e9a0ef1c0ff13d243eceb2c1965b4a91cd', declarations: '36e7006c508d95d5567a1aefde2a0786789c268329c12f00bd047e2878dde6db',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036', duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac', keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac',
ruleDeclarationSequences: 'e4d989c091659ad82e79de7281c2b94b7b76d5f032f2a814e630275b7f7b7c4b', ruleDeclarationSequences: 'afbf47d7d026eaf47d402e4803364c083a6c1a031165aec7926d9b7afab6d030',
selectors: 'd1948bb0fb3899264af80cd1f86b9c8e9cdcf1a4473ef77a0dec4ffad10d1f8d', selectors: 'beef8401967d55300d72ea04a2f6d8b95f653bbafc5269d28ed2480c9a34631d',
variableReferences: '00551e699e5530d624d364db9e546c0ffc05d7c79d04abc42930a98be874b3bf', variableReferences: 'c7dce56ef422f68a5e54ea8b6514ffd3dec3ae948e4bfc77098efb7342e27e2c',
witnesses: '07f462edf94d90909902283f5af130f290c957c3f10ddaa5a2b2339eb64d468a', witnesses: '8126287c546fee33b029d793db116533bcb5f542ea03e686174a07299d0ee270',
}, },
}; };
@@ -209,7 +209,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', () => { test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root); const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 729); assert.equal(witnesses.length, 730);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses }); const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts); assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -405,8 +405,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-SYp2glgN.css']); assert.deepEqual(assets, ['index-BkhdgoL9.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, 123402); assert.equal(built.byteLength, 123588);
assert.equal(sha256(built), '8050f9e31292529b97d716d38140da72364c26368af92dd5b3a8d56c9fe752ad'); assert.equal(sha256(built), '53f8778322796cb24321b5a876862a14fc48d757e68f9ce63bee4f95f5b56fa9');
}); });
+1 -1
View File
@@ -32,7 +32,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
.map(({ source }) => source) .map(({ source }) => source)
.join('\n'); .join('\n');
assert.equal((production.match(/<Tooltip\b/g) || []).length, 9); assert.equal((production.match(/<Tooltip\b/g) || []).length, 10);
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2); assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
assert.equal((production.match(/<RailAction\b/g) || []).length, 5); assert.equal((production.match(/<RailAction\b/g) || []).length, 5);
assert.equal((production.match(/<Drawer\b/g) || []).length, 5); assert.equal((production.match(/<Drawer\b/g) || []).length, 5);