Add device identity tooltips and MAC validation
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.24.0',
|
||||
gatewayClient: '0.25.0',
|
||||
macClient: '0.24.1',
|
||||
gatewayClient: '0.25.1',
|
||||
gatewayBackend: '0.25.0',
|
||||
});
|
||||
|
||||
|
||||
@@ -387,6 +387,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
const groupStart = group !== previousGroup;
|
||||
const hasName = Boolean(device.alias || device.hostname);
|
||||
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
|
||||
const identityTooltipId = `device-identity-${device.id}`;
|
||||
const editing = editingId === device.id;
|
||||
const saving = savingId === device.id;
|
||||
const seen = formatLastSeen(device.lastSeenAt);
|
||||
@@ -452,7 +453,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
</span>}
|
||||
|
||||
<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 ? (
|
||||
<input
|
||||
className="client-device-alias-input"
|
||||
@@ -461,6 +462,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
maxLength={64}
|
||||
autoFocus
|
||||
aria-label="Название устройства"
|
||||
aria-describedby={identityTooltipId}
|
||||
aria-busy={saving}
|
||||
disabled={saving}
|
||||
onChange={(event) => setAlias(event.target.value)}
|
||||
@@ -470,9 +472,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
}}
|
||||
/>
|
||||
) : hasName && <button
|
||||
className="client-device-alias-trigger"
|
||||
className={`client-device-alias-trigger${device.alias ? ' is-custom-name' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Изменить название ${title}`}
|
||||
aria-describedby={identityTooltipId}
|
||||
onClick={() => startEditing(device)}
|
||||
>{title}</button>}
|
||||
{(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' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
||||
aria-describedby={identityTooltipId}
|
||||
onClick={() => copyDeviceIp(device)}
|
||||
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
|
||||
<Tooltip id={identityTooltipId}>Hostname: {device.hostname || '—'} · MAC: {device.mac}</Tooltip>
|
||||
</h4>
|
||||
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
|
||||
<button
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Device extends Record<string, unknown> {
|
||||
id: string;
|
||||
alias: string | null;
|
||||
hostname: string | null;
|
||||
mac: string;
|
||||
ip: string | null;
|
||||
lastSeenAt: string | null;
|
||||
status: DeviceStatus;
|
||||
@@ -113,6 +114,8 @@ function validDevice(value: unknown): value is Device {
|
||||
&& /^dev_[a-f0-9]{16}$/.test(value.id)
|
||||
&& nullableString(value.alias)
|
||||
&& nullableString(value.hostname)
|
||||
&& typeof value.mac === 'string'
|
||||
&& /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(value.mac)
|
||||
&& nullableString(value.ip)
|
||||
&& nullableTimestamp(value.lastSeenAt)
|
||||
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
margin: 0;
|
||||
gap: 5px;
|
||||
font: var(--type-item-title);
|
||||
@@ -314,6 +314,12 @@
|
||||
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 {
|
||||
width: var(--alias-width);
|
||||
min-width: 1px;
|
||||
@@ -324,9 +330,9 @@
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
caret-color: var(--client-accent);
|
||||
font: var(--type-item-title);
|
||||
letter-spacing: var(--type-item-title-tracking);
|
||||
text-transform: var(--type-item-title-transform);
|
||||
font: var(--type-section-title);
|
||||
letter-spacing: var(--type-section-title-tracking);
|
||||
text-transform: var(--type-section-title-transform);
|
||||
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, /onClick=\{\(\) => startEditing\(device\)\}/);
|
||||
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, /aliasBaseline\.current = \{ id: device\.id, value \}/);
|
||||
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-device-addresses|client-device-manufacturer|client-device-meta/);
|
||||
assert.doesNotMatch(panel, /device\.interface/);
|
||||
assert.doesNotMatch(panel, /device\.mac/);
|
||||
assert.doesNotMatch(panel, /client-device-identity|Пояснение идентификации устройства|ⓘ/);
|
||||
assert.match(panel, /identityTooltipId = `device-identity-\$\{device\.id\}`/);
|
||||
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, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/);
|
||||
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, /\.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-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.doesNotMatch(styles, /\.client-device-alias \{/);
|
||||
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',
|
||||
alias: null,
|
||||
hostname: null,
|
||||
mac: '00:11:22:33:44:55',
|
||||
ip: null,
|
||||
lastSeenAt: null,
|
||||
status: 'online',
|
||||
@@ -96,6 +97,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
||||
{ ...valid, traffic: undefined },
|
||||
{ ...valid, source: undefined },
|
||||
{ ...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], uploadBytes: '-1' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], proxyUploadBytes: 1 }] },
|
||||
|
||||
@@ -39,24 +39,24 @@ const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 749,
|
||||
customProperties: 103,
|
||||
declarations: 3200,
|
||||
declarations: 3203,
|
||||
important: 0,
|
||||
keyframes: 56,
|
||||
media: 13,
|
||||
rules: 917,
|
||||
variableReferences: 791,
|
||||
rules: 918,
|
||||
variableReferences: 794,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: 'a47dbf5212045c865de7a41d420e78df8b992a6ec1b06fb18a8d459cd480f0ae',
|
||||
customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4',
|
||||
declarations: '198ccda82654a42a5ba3f452fed7a6e9a0ef1c0ff13d243eceb2c1965b4a91cd',
|
||||
declarations: '36e7006c508d95d5567a1aefde2a0786789c268329c12f00bd047e2878dde6db',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
|
||||
keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac',
|
||||
ruleDeclarationSequences: 'e4d989c091659ad82e79de7281c2b94b7b76d5f032f2a814e630275b7f7b7c4b',
|
||||
selectors: 'd1948bb0fb3899264af80cd1f86b9c8e9cdcf1a4473ef77a0dec4ffad10d1f8d',
|
||||
variableReferences: '00551e699e5530d624d364db9e546c0ffc05d7c79d04abc42930a98be874b3bf',
|
||||
witnesses: '07f462edf94d90909902283f5af130f290c957c3f10ddaa5a2b2339eb64d468a',
|
||||
ruleDeclarationSequences: 'afbf47d7d026eaf47d402e4803364c083a6c1a031165aec7926d9b7afab6d030',
|
||||
selectors: 'beef8401967d55300d72ea04a2f6d8b95f653bbafc5269d28ed2480c9a34631d',
|
||||
variableReferences: 'c7dce56ef422f68a5e54ea8b6514ffd3dec3ae948e4bfc77098efb7342e27e2c',
|
||||
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', () => {
|
||||
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);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
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);
|
||||
|
||||
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]));
|
||||
assert.equal(built.byteLength, 123402);
|
||||
assert.equal(sha256(built), '8050f9e31292529b97d716d38140da72364c26368af92dd5b3a8d56c9fe752ad');
|
||||
assert.equal(built.byteLength, 123588);
|
||||
assert.equal(sha256(built), '53f8778322796cb24321b5a876862a14fc48d757e68f9ce63bee4f95f5b56fa9');
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
|
||||
.map(({ source }) => source)
|
||||
.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(/<RailAction\b/g) || []).length, 5);
|
||||
assert.equal((production.match(/<Drawer\b/g) || []).length, 5);
|
||||
|
||||
Reference in New Issue
Block a user