Show device identity details and resolve hostnames
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 12:39:27 +03:00
parent 501c498edf
commit 3566f4bc0b
10 changed files with 256 additions and 82 deletions
+37
View File
@@ -116,6 +116,43 @@ test('malformed persisted devices and remote observations cannot enter canonical
]);
});
test('device inventory resolves hostnames without making reverse lookup a refresh dependency', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-hostname-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} });
const observedAt = '2026-08-11T12:00:00.000Z';
const mac = '00:11:22:33:44:55';
let ip = '192.168.50.10';
let lookupFails = false;
const lookups = [];
const service = createDeviceInventoryService({
store,
observe: () => ({
observedAt,
error: null,
observations: [{ ip, mac, interface: 'br0', observedAt, active: true }],
}),
resolveHostname: async (address) => {
lookups.push(address);
if (lookupFails) throw new Error('reverse DNS unavailable');
return 'living-room.local.';
},
now: () => new Date(observedAt),
});
let snapshot = await service.refresh();
assert.equal(snapshot.devices[0].hostname, 'living-room.local');
snapshot = await service.refresh();
assert.deepEqual(lookups, ['192.168.50.10']);
ip = '192.168.50.11';
lookupFails = true;
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].ip, ip);
assert.equal(snapshot.devices[0].hostname, 'living-room.local');
assert.deepEqual(lookups, ['192.168.50.10', '192.168.50.11']);
});
test('device inventory discovers, merges, persists metadata and expires devices after 30 days', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
+18 -14
View File
@@ -49,12 +49,13 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(deviceRoute, /pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
assert.match(deviceRoute, /DEVICE_POLICY_PATH[\s\S]*deviceInventory\.setPolicy/);
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
assert.match(panel, /copyText\(device\.ip\)/);
assert.match(panel, /copyText\(value\)/);
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/);
assert.match(panel, /const title = device\.alias \|\| device\.hostname \|\| device\.ip \|\| 'Неизвестное устройство'/);
assert.match(panel, /client-device-fallback-name[\s\S]*client-device-identity-details[\s\S]*<b>IP<\/b>[\s\S]*<b>MAC<\/b>[\s\S]*<b>Host<\/b>/);
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 client-tooltip-anchor\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}/);
assert.match(panel, /className=\{`client-device-name-heading\$\{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\}/);
@@ -64,7 +65,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/);
assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/);
assert.match(panel, /COPY_FEEDBACK_MS = 800/);
assert.match(panel, /IP \$\{device\.ip\} скопирован/);
assert.match(panel, /`\$\{field\} \$\{value\} скопирован`/);
assert.doesNotMatch(panel, /client-device-name-feedback|client-device-name-primary/);
assert.doesNotMatch(panel, /client-device-title/);
assert.match(panel, /online \? 'В сети' : <TextMorph from="Не в сети" to=\{seen\.relative\} \/>/);
@@ -73,9 +74,11 @@ 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.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, /role="group" aria-label=\{`Технические данные устройства \$\{title\}`\}/);
assert.match(panel, /onClick=\{\(\) => copyDeviceValue\(device, 'IP', device\.ip!\)\}/);
assert.match(panel, /onClick=\{\(\) => copyDeviceValue\(device, 'MAC', device\.mac\)\}/);
assert.match(panel, /\{device\.hostname && <button[\s\S]*copyDeviceValue\(device, 'Hostname', device\.hostname!\)/);
assert.doesNotMatch(panel, /identityTooltipId|Hostname: \{device\.hostname/);
assert.match(panel, /device\.confidence === 'ambiguous'/);
assert.match(panel, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/);
assert.match(panel, /Трафик временно не обновляется/);
@@ -162,13 +165,14 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
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\)/);
assert.match(styles, /\.client-device-identity-copy span \{[\s\S]*font: var\(--type-data\)/);
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font: var\(--type-micro\)/);
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font: var\(--type-control\)/);
assert.match(styles, /\.client-device-name-separator \{[\s\S]*color: var\(--client-muted\)/);
assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/);
assert.match(styles, /\.client-device-identity-details \{[\s\S]*top: 100%;[\s\S]*pointer-events: none/);
assert.match(styles, /\.client-device-name-heading:hover > \.client-device-identity-details,[\s\S]*pointer-events: auto/);
assert.match(styles, /\.client-device-identity-copy\.is-copied \{[\s\S]*client-device-copy 800ms/);
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 0 0/);
assert.match(styles, /\.client-device-main > h4\.is-address-only \{[\s\S]*flex: 0 1 auto/);
assert.match(styles, /\.client-device-main > h4 \{[\s\S]*flex: 0 1 auto/);
assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/);
assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/);
assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/);
@@ -185,15 +189,15 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/);
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*transition: none;[\s\S]*animation: none/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-identity-details[\s\S]*\.client-device-identity-copy[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*transition: none;[\s\S]*animation: none/);
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
});
test('device copy feedback stays keyed per device', () => {
assert.match(panel, /copyTimers = useRef\(new Map[\s\S]*copyTimers\.current\.get\(device\.id\)[\s\S]*copyTimers\.current\.set\(device\.id/);
assert.match(panel, /copyAttempts = useRef\(new Map[\s\S]*copyAttempts\.current\.set\(device\.id, attempt\)[\s\S]*await copyText\(device\.ip\)[\s\S]*copyAttempts\.current\.get\(device\.id\) !== attempt/);
assert.match(panel, /setCopyFeedback\(\(current\) => \(\{ \.\.\.current, \[device\.id\]: feedback \}\)\)/);
assert.match(panel, /copyAttempts = useRef\(new Map[\s\S]*copyAttempts\.current\.set\(device\.id, attempt\)[\s\S]*await copyText\(value\)[\s\S]*copyAttempts\.current\.get\(device\.id\) !== attempt/);
assert.match(panel, /setCopyFeedback\(\(current\) => \(\{ \.\.\.current, \[device\.id\]: \{ \.\.\.feedback, field \} \}\)\)/);
assert.match(panel, /const feedback = copyFeedback\[device\.id\]/);
});
+17 -17
View File
@@ -37,26 +37,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 749,
cascadeEdges: 771,
customProperties: 103,
declarations: 3203,
declarations: 3247,
important: 0,
keyframes: 56,
media: 13,
rules: 918,
variableReferences: 794,
rules: 922,
variableReferences: 797,
},
hashes: {
cascadeEdges: 'a47dbf5212045c865de7a41d420e78df8b992a6ec1b06fb18a8d459cd480f0ae',
customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4',
declarations: '36e7006c508d95d5567a1aefde2a0786789c268329c12f00bd047e2878dde6db',
cascadeEdges: 'e1b9f75210ef7258d9cb5b9e332de529b2ccceaf92b9b86eabcd0b62fefb5c98',
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
declarations: '6e30102fba87a4c28ea429159a13239a313ea8d18928423763bb166e0456f8cc',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac',
ruleDeclarationSequences: 'afbf47d7d026eaf47d402e4803364c083a6c1a031165aec7926d9b7afab6d030',
selectors: 'beef8401967d55300d72ea04a2f6d8b95f653bbafc5269d28ed2480c9a34631d',
variableReferences: 'c7dce56ef422f68a5e54ea8b6514ffd3dec3ae948e4bfc77098efb7342e27e2c',
witnesses: '8126287c546fee33b029d793db116533bcb5f542ea03e686174a07299d0ee270',
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
ruleDeclarationSequences: '9dacb5d7407717ae041cabf6e8e75af7cfccfb7b5e4c387880468db409e93fbd',
selectors: '8456f64c608e0411bbc56c0c955d25383391f019fa5af0648a72d40564cfee1a',
variableReferences: '42661286e6150115a367068908f79c79951e24fb3fcf8775c2b083bb2d520e38',
witnesses: 'd992876195d2c68c00d687c5a174e5657e3480ad8496251afe52d20c9571e82f',
},
};
@@ -187,7 +187,7 @@ test('client typography uses the shared semantic scale outside the token owner',
for (const selector of [
'.client-duration', '.client-power-section.is-gateway .client-duration', '.client-proxy-address',
'.client-device-ip', '.client-device-traffic strong',
'.client-device-identity-copy span', '.client-device-traffic strong',
'.client-device-traffic-point-tooltip', '.client-device-traffic-point-tooltip time',
'.client-device-traffic-point-tooltip strong', '.client-gateway-traffic-total > strong',
'.client-gateway-traffic-speed', '.client-diagnostics-table code',
@@ -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, 730);
assert.equal(witnesses.length, 737);
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-BkhdgoL9.css']);
assert.deepEqual(assets, ['index-CGpprjFm.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 123588);
assert.equal(sha256(built), '53f8778322796cb24321b5a876862a14fc48d757e68f9ce63bee4f95f5b56fa9');
assert.equal(built.byteLength, 125113);
assert.equal(sha256(built), 'f9ddcf2a43d81f42abe53303087966b5ec4ecfd3c032bd98cf1dc2a805b47364');
});
+1 -1
View File
@@ -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, 10);
assert.equal((production.match(/<Tooltip\b/g) || []).length, 9);
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);