{
+ if (node) deviceNodes.current.set(device.id, node);
+ else deviceNodes.current.delete(device.id);
+ }}
+ className={`client-device is-${device.status}`}
+ key={device.id}
+ >
{STATUS_LABELS[device.status]}
{editing ? (
diff --git a/src/web/styles.css b/src/web/styles.css
index aed7eb1..4b215bd 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -723,6 +723,98 @@ p {
margin-bottom: 28px;
}
+.client-devices-kicker {
+ width: max-content;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.client-devices-refresh-wrap {
+ width: 24px;
+ height: 24px;
+ display: grid;
+ place-items: center;
+}
+
+.client-devices-refresh {
+ position: relative;
+ width: 24px;
+ height: 24px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--client-muted);
+ cursor: pointer;
+ transition: color 220ms ease, filter 300ms ease;
+}
+
+.client-devices-refresh:hover:not(:disabled),
+.client-devices-refresh:focus-visible,
+.client-devices-refresh.is-refreshing {
+ color: var(--client-accent);
+ filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 44%, transparent));
+}
+
+.client-devices-refresh:focus-visible {
+ outline: 2px solid var(--client-accent);
+ outline-offset: 2px;
+}
+
+.client-devices-refresh:disabled {
+ cursor: wait;
+}
+
+.client-devices-refresh-ring,
+.client-devices-refresh-icon {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.client-devices-refresh-ring {
+ transform: rotate(-90deg);
+}
+
+.client-devices-refresh-ring circle {
+ stroke-width: 1;
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ opacity: 0.68;
+ animation: client-devices-refresh-progress 15s linear forwards;
+}
+
+.client-devices-refresh-icon {
+ width: 12px;
+ height: 12px;
+ stroke-width: 1.8;
+}
+
+.client-devices-refresh.is-refreshing .client-devices-refresh-ring circle {
+ stroke-dashoffset: 0;
+ animation: none;
+}
+
+.client-devices-refresh.is-refreshing .client-devices-refresh-icon {
+ animation: client-spin 900ms linear infinite;
+}
+
+.client-instructions-header .client-devices-refresh-wrap > .client-tooltip {
+ text-transform: none;
+}
+
+@keyframes client-devices-refresh-progress {
+ to { stroke-dashoffset: 0; }
+}
+
.client-devices-source,
.client-devices-error,
.client-devices-empty {
@@ -922,14 +1014,14 @@ p {
white-space: nowrap;
}
-.client-device-pin-wrap > .client-tooltip {
+.client-device-pin-wrap.client-tooltip-anchor > .client-tooltip {
right: 0;
left: auto;
transform: translate(0, 2px);
}
-.client-device-pin-wrap:hover > .client-tooltip,
-.client-device-pin-wrap:has(> :focus-visible) > .client-tooltip {
+.client-device-pin-wrap.client-tooltip-anchor:hover > .client-tooltip,
+.client-device-pin-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
transform: translate(0, 0);
}
@@ -3987,6 +4079,9 @@ p {
.client-device-edit,
.client-device-edit svg,
.client-device-edit-wrap,
+ .client-devices-refresh,
+ .client-devices-refresh-ring circle,
+ .client-devices-refresh-icon,
.client-text-morph-value,
.client-subscription-edit,
.client-subscription-edit::after,
@@ -3998,6 +4093,10 @@ p {
animation: none;
}
+ .client-devices-refresh-ring circle {
+ stroke-dashoffset: 0;
+ }
+
.client-local-rules,
.client-local-rules-toggle,
.client-local-rules-toggle svg,
diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js
index 19c5ed0..42d4103 100644
--- a/test/server/device-inventory.test.js
+++ b/test/server/device-inventory.test.js
@@ -30,14 +30,21 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
], current.toISOString()),
error: null,
};
+ let observeCalls = 0;
const service = createDeviceInventoryService({
store,
- observe: async () => observation,
+ observe: async () => {
+ observeCalls += 1;
+ return observation;
+ },
vendor,
now: () => current,
});
- let snapshot = await service.refresh();
+ const [firstRefresh, sharedRefresh] = await Promise.all([service.refresh(), service.refresh()]);
+ let snapshot = firstRefresh;
+ assert.strictEqual(sharedRefresh, firstRefresh);
+ assert.equal(observeCalls, 1);
assert.equal(snapshot.devices.length, 1);
assert.equal(snapshot.devices[0].manufacturer, 'Example Devices');
assert.equal(snapshot.devices[0].status, 'online');
diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js
index f62c91e..c90e6a4 100644
--- a/test/web/device-inventory-contract.test.js
+++ b/test/web/device-inventory-contract.test.js
@@ -8,12 +8,19 @@ import { formatLastSeen } from '../../src/web/utils/format.js';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
const panel = fs.readFileSync(path.join(root, 'src/web/components/DevicesPanel.jsx'), 'utf8');
+const api = fs.readFileSync(path.join(root, 'src/web/api.js'), 'utf8');
+const server = fs.readFileSync(path.join(root, 'src/server/index.js'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
test('Gateway device inventory uses the existing accessible responsive drawer', () => {
assert.match(overview, /isGateway &&