Add device identity tooltips and MAC validation
This commit is contained in:
@@ -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',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }] },
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user