@iobroker/dm-gui-components 10.0.20 → 10.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,15 @@
1
1
  import React from 'react';
2
- import { IconButton, InputAdornment, TextField, Toolbar, Tooltip, LinearProgress, Select, MenuItem, Box, Card, CardActionArea, CardContent, Typography, Menu, Checkbox, ListSubheader, } from '@mui/material';
2
+ import { IconButton, InputAdornment, TextField, Toolbar, Tooltip, LinearProgress, Select, MenuItem, Card, CardActionArea, CardContent, Typography, Menu, Checkbox, ListSubheader, } from '@mui/material';
3
3
  import { ArrowBack, Clear, QuestionMark, Refresh, FilterAlt, FilterAltOff, SystemUpdateAlt, BatteryAlert, Tune, } from '@mui/icons-material';
4
4
  import { I18n, DeviceTypeIcon, Icon, InfoBox } from '@iobroker/gui-components';
5
- import DeviceCard, { DeviceCardSkeleton } from './DeviceCard';
5
+ import DeviceCard, { DeviceCardSkeleton, CARD_WIDTH, CARD_MIN_HEIGHT, CARD_MARGIN, SMALL_CARD_WIDTH, SMALL_CARD_MIN_HEIGHT, SMALL_CARD_MARGIN, } from './DeviceCard';
6
6
  import { getTranslation, renderIcon } from './Utils';
7
7
  import Communication from './Communication';
8
8
  import InstanceActionButton from './InstanceActionButton';
9
9
  import { StatusIndicators } from './StatusIndicator';
10
10
  import { StateOrObjectHandler } from './StateOrObjectHandler';
11
+ import { DeviceFieldsResolver } from './DeviceFields';
12
+ import { LazyRender, LazyRenderObserver } from './LazyRender';
11
13
  import de from './i18n/de.json';
12
14
  import en from './i18n/en.json';
13
15
  import ru from './i18n/ru.json';
@@ -19,6 +21,44 @@ import es from './i18n/es.json';
19
21
  import pl from './i18n/pl.json';
20
22
  import uk from './i18n/uk.json';
21
23
  import zhCn from './i18n/zh-cn.json';
24
+ /**
25
+ * The key of a device. It is used as React key, for the resolved fields and for the filter, so it is
26
+ * computed on every render for every device. `JSON.stringify` adds up noticeably with a few hundred
27
+ * devices, therefore the result is cached per device object.
28
+ */
29
+ const deviceKeys = new WeakMap();
30
+ function deviceKey(device) {
31
+ let key = deviceKeys.get(device);
32
+ if (key === undefined) {
33
+ key = JSON.stringify(device.id);
34
+ deviceKeys.set(device, key);
35
+ }
36
+ return key;
37
+ }
38
+ /**
39
+ * How many skeletons are shown at most while loading. They stand for devices that are not there yet,
40
+ * and a few hundred pulsing placeholders cost more than the cards they are waiting for.
41
+ */
42
+ const MAX_SKELETONS = 12;
43
+ /**
44
+ * The toolbar is grey in both themes, so its content is always white. Buttons and inputs bring their own
45
+ * colors (primary blue, the text color of the theme), which are hard to read on grey and are overridden here.
46
+ */
47
+ const TOOLBAR_SX = {
48
+ backgroundColor: '#777',
49
+ color: '#fff',
50
+ '& .MuiButtonBase-root.Mui-disabled': { color: 'rgba(255, 255, 255, 0.4)' },
51
+ '& .MuiInputBase-root, & .MuiSelect-icon': { color: 'inherit' },
52
+ '& .MuiInputBase-input::placeholder': { opacity: 0.7 },
53
+ '& .MuiInput-underline::before': { borderBottomColor: 'rgba(255, 255, 255, 0.7)' },
54
+ '& .MuiInput-underline:hover:not(.Mui-disabled, .Mui-error)::before': { borderBottomColor: '#fff' },
55
+ '& .MuiInput-underline::after': { borderBottomColor: '#fff' },
56
+ };
57
+ /** Marks an active toggle button in the toolbar, as the primary color is not readable there */
58
+ const TOOLBAR_TOGGLE_ACTIVE_SX = {
59
+ backgroundColor: 'rgba(255, 255, 255, 0.25)',
60
+ '&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.35)' },
61
+ };
22
62
  /** Returns true if any of the device status objects carries a battery value */
23
63
  function hasBatteryStatus(status) {
24
64
  if (!status || typeof status === 'string') {
@@ -43,14 +83,32 @@ export default class DeviceList extends Communication {
43
83
  alive = null;
44
84
  lastTriggerLoad = 0;
45
85
  filterTimeout = null;
46
- /** Resolved model value per device (stringified id -> model), reported by the cards to build the model dropdown */
47
- modelValues = new Map();
48
86
  language = I18n.getLanguage();
49
- /** Subscriptions for the instance-wide indicators in the toolbar */
87
+ /**
88
+ * One handler for the toolbar indicators and for all cards, so that several devices referring to
89
+ * the same object or state share a single subscription on the socket.
90
+ */
50
91
  stateOrObjectHandler;
92
+ /** Resolves the device fields that may be bound to a state or an object (name, model, ...) */
93
+ fieldsResolver;
94
+ /** One `IntersectionObserver` for all cards: only what is near the viewport is really rendered */
95
+ lazyObserver = new LazyRenderObserver();
96
+ /** The scrolling container of the card list; it is the root of `lazyObserver` */
97
+ containerRef = React.createRef();
98
+ /** A load is running. A second one must not tear down the same list in parallel */
99
+ loadingDevices = false;
100
+ /** A load was requested while another one was still running */
101
+ reloadRequested = false;
102
+ /** Devices of the running initial load that are not published to the state yet */
103
+ pendingDevices = null;
104
+ pendingTotal;
105
+ flushTimer = null;
106
+ /** Memoized result of `getHiddenIndicators`, so that all cards keep getting the same array */
107
+ hiddenIndicatorsCache = null;
51
108
  constructor(props) {
52
109
  super(props);
53
110
  this.stateOrObjectHandler = new StateOrObjectHandler(this.props.socket);
111
+ this.fieldsResolver = new DeviceFieldsResolver(this.stateOrObjectHandler, this.onFieldsChanged);
54
112
  if (!DeviceList.i18nInitialized) {
55
113
  DeviceList.i18nInitialized = true;
56
114
  I18n.extendTranslations({
@@ -84,6 +142,7 @@ export default class DeviceList extends Communication {
84
142
  modelOptions: [],
85
143
  indicatorVisibility: {},
86
144
  indicatorsAnchor: null,
145
+ fieldsVersion: 0,
87
146
  };
88
147
  if (this.props.selectedInstance === undefined) {
89
148
  // Start with the root page that shows all instances as cards
@@ -153,9 +212,8 @@ export default class DeviceList extends Communication {
153
212
  window.localStorage.removeItem('dmSelectedInstance');
154
213
  this.props.onInstanceChanged?.('');
155
214
  this.alive = null;
156
- this.setState({
215
+ this.applyDevices([], {
157
216
  selectedInstance: '',
158
- devices: [],
159
217
  totalDevices: undefined,
160
218
  instanceInfo: null,
161
219
  alive: null,
@@ -169,6 +227,7 @@ export default class DeviceList extends Communication {
169
227
  this.setState({ dmInstances: null }, () => this.loadAdapters().catch(console.error));
170
228
  }
171
229
  async componentDidMount() {
230
+ this.lazyObserver.setRoot(this.containerRef.current);
172
231
  let alive = false;
173
232
  // If an instance selector must be shown
174
233
  if (this.props.selectedInstance === undefined) {
@@ -207,10 +266,98 @@ export default class DeviceList extends Communication {
207
266
  }
208
267
  }
209
268
  componentWillUnmount() {
269
+ super.componentWillUnmount();
270
+ this.cancelDeviceFlush();
271
+ this.fieldsResolver.destroy();
272
+ this.stateOrObjectHandler.destroy();
273
+ this.lazyObserver.destroy();
274
+ if (this.filterTimeout) {
275
+ clearTimeout(this.filterTimeout);
276
+ this.filterTimeout = null;
277
+ }
210
278
  if (this.state.selectedInstance) {
211
279
  this.props.socket.unsubscribeState(`system.adapter.${this.state.selectedInstance}.alive`, this.aliveHandler);
212
280
  }
213
281
  }
282
+ /**
283
+ * The side effects that used to be triggered from `render()`. A render must not have any, and
284
+ * the `setTimeout` there was executed on every single render.
285
+ */
286
+ componentDidUpdate() {
287
+ // The scrolling container only exists after the first render
288
+ this.lazyObserver.setRoot(this.containerRef.current);
289
+ if ((this.props.triggerLoad || 0) !== this.lastTriggerLoad) {
290
+ this.lastTriggerLoad = this.props.triggerLoad || 0;
291
+ this.loadDeviceList();
292
+ }
293
+ // if instance changed
294
+ if (this.lastInstance !== this.state.selectedInstance) {
295
+ this.lastInstance = this.state.selectedInstance;
296
+ // The indicator visibility is stored per instance
297
+ this.setState({
298
+ indicatorVisibility: DeviceList.loadIndicatorVisibility(this.state.selectedInstance),
299
+ indicatorsAnchor: null,
300
+ });
301
+ if (this.state.selectedInstance) {
302
+ this.loadAllData().catch(error => console.error(error));
303
+ }
304
+ else {
305
+ this.loadDeviceList();
306
+ }
307
+ }
308
+ if (this.props.selectedInstance && this.props.selectedInstance !== this.state.selectedInstance) {
309
+ this.setState({ selectedInstance: this.props.selectedInstance });
310
+ }
311
+ }
312
+ /**
313
+ * A value bound to a state or an object arrived. The cards are `PureComponent`s and receive the
314
+ * values through their `fields` property, so the list only has to trigger a re-render.
315
+ */
316
+ onFieldsChanged = () => {
317
+ const modelOptions = this.fieldsResolver.getModels();
318
+ const modelsChanged = modelOptions.length !== this.state.modelOptions.length ||
319
+ modelOptions.some((model, i) => model !== this.state.modelOptions[i]);
320
+ this.setState({
321
+ fieldsVersion: this.state.fieldsVersion + 1,
322
+ modelOptions: modelsChanged ? modelOptions : this.state.modelOptions,
323
+ });
324
+ };
325
+ /**
326
+ * Publish a new device list.
327
+ *
328
+ * The resolver is updated *before* the state, because it resolves literal values synchronously -
329
+ * the cards and the filter therefore already have their values on the very first render.
330
+ */
331
+ applyDevices(devices, state) {
332
+ this.fieldsResolver.setDevices(devices.map(device => ({ key: deviceKey(device), device })));
333
+ this.setState({ ...state, devices });
334
+ }
335
+ /**
336
+ * Publish the devices loaded so far, but at most every 100 ms: a fast backend delivers a dozen
337
+ * batches in a row, and every single one of them would otherwise re-render the complete list.
338
+ */
339
+ scheduleDeviceFlush(devices, total) {
340
+ this.pendingDevices = devices;
341
+ this.pendingTotal = total;
342
+ if (this.flushTimer) {
343
+ return;
344
+ }
345
+ this.flushTimer = setTimeout(() => {
346
+ this.flushTimer = null;
347
+ const pending = this.pendingDevices;
348
+ if (pending) {
349
+ this.pendingDevices = null;
350
+ this.applyDevices(pending, { loading: true, totalDevices: this.pendingTotal });
351
+ }
352
+ }, 100);
353
+ }
354
+ cancelDeviceFlush() {
355
+ if (this.flushTimer) {
356
+ clearTimeout(this.flushTimer);
357
+ this.flushTimer = null;
358
+ }
359
+ this.pendingDevices = null;
360
+ }
214
361
  aliveHandler = (id, state) => {
215
362
  if (this.state.selectedInstance && id === `system.adapter.${this.state.selectedInstance}.alive`) {
216
363
  const alive = !!state?.val;
@@ -237,9 +384,20 @@ export default class DeviceList extends Communication {
237
384
  return new Promise(resolve => this.setState({ instanceInfo, apiVersionError: !['v1', 'v2', 'v3'].includes(instanceInfo.apiVersion) }, () => resolve(instanceInfo)));
238
385
  }
239
386
  /**
240
- * Load devices
387
+ * Load devices.
388
+ *
389
+ * The list is deliberately *not* emptied first: as long as the keys stay the same React keeps the
390
+ * existing cards, so a reload - the matter adapter triggers one on every `updateController`
391
+ * message - no longer unmounts and remounts a few hundred cards with all their subscriptions.
241
392
  */
242
393
  loadDeviceList() {
394
+ if (this.loadingDevices) {
395
+ // Remember the request and repeat the load once the running one has finished, instead of
396
+ // running two loads over the same list at the same time.
397
+ this.reloadRequested = true;
398
+ return;
399
+ }
400
+ this.loadingDevices = true;
243
401
  this.setState({ loading: true }, async () => {
244
402
  console.log(`Loading devices for ${this.state.selectedInstance}...`);
245
403
  let alive = this.alive;
@@ -268,14 +426,20 @@ export default class DeviceList extends Communication {
268
426
  alive = false;
269
427
  }
270
428
  }
429
+ // Only the very first load shows the devices step by step (with skeletons for the ones
430
+ // that are still missing). A reload keeps the current list until the new one is complete,
431
+ // so the already rendered cards survive.
432
+ const incremental = !this.state.devices.length;
271
433
  let devices = [];
272
434
  try {
273
435
  this.alive = alive;
274
- this.setState({ devices, loading: !!alive, alive });
436
+ this.setState({ loading: !!alive, alive });
275
437
  if (alive) {
276
438
  await this.loadDevices((batch, total) => {
277
439
  devices = devices.concat(batch);
278
- this.setState({ devices, loading: true, totalDevices: total });
440
+ if (incremental) {
441
+ this.scheduleDeviceFlush(devices, total);
442
+ }
279
443
  console.log(`Loaded ${devices.length} of ${total} devices...`);
280
444
  });
281
445
  }
@@ -284,21 +448,27 @@ export default class DeviceList extends Communication {
284
448
  console.error(error);
285
449
  devices = [];
286
450
  }
287
- this.setState({ devices, loading: false, totalDevices: devices.length });
451
+ this.cancelDeviceFlush();
452
+ this.applyDevices(devices, { loading: false, totalDevices: devices.length });
288
453
  console.log(`Loaded ${devices.length} devices for ${this.state.selectedInstance}`);
454
+ this.loadingDevices = false;
455
+ if (this.reloadRequested) {
456
+ this.reloadRequested = false;
457
+ setTimeout(() => this.loadDeviceList(), 0);
458
+ }
289
459
  });
290
460
  }
291
461
  updateDevice(update) {
292
462
  const updateId = JSON.stringify(update.id);
293
- this.setState({ devices: this.state.devices.map(d => (JSON.stringify(d.id) === updateId ? update : d)) });
463
+ this.applyDevices(this.state.devices.map(d => (deviceKey(d) === updateId ? update : d)), {});
294
464
  }
295
465
  deleteDevice(deviceId) {
296
466
  const deleteId = JSON.stringify(deviceId);
297
- const devices = this.state.devices.filter(d => JSON.stringify(d.id) !== deleteId);
467
+ const devices = this.state.devices.filter(d => deviceKey(d) !== deleteId);
298
468
  const totalDevices = this.state.totalDevices && devices.length < this.state.devices.length
299
469
  ? this.state.totalDevices - 1
300
470
  : undefined;
301
- this.setState({ devices, totalDevices });
471
+ this.applyDevices(devices, { totalDevices });
302
472
  }
303
473
  getText(text) {
304
474
  if (typeof text === 'object') {
@@ -388,27 +558,38 @@ export default class DeviceList extends Communication {
388
558
  info.title ? (React.createElement(Typography, { variant: "body2", color: "textSecondary", style: { textAlign: 'center' } }, info.title)) : null))));
389
559
  });
390
560
  }
391
- /** Collects the resolved model values reported by the cards and keeps the distinct, sorted list in state */
392
- reportModel = (deviceId, model) => {
393
- const key = JSON.stringify(deviceId);
394
- if (model) {
395
- if (this.modelValues.get(key) === model) {
396
- return;
561
+ /**
562
+ * Apply the text filter and the "only updatable" / "only battery problem" filters.
563
+ *
564
+ * This used to be done by every card for itself. A filtered out card stayed mounted with all its
565
+ * subscriptions and only rendered nothing, so filtering did not reduce the load at all - and a
566
+ * card that is not rendered cannot filter itself in the first place.
567
+ */
568
+ filterDevices(devices) {
569
+ const filter = (this.props.embedded ? this.props.filter : this.state.filter)?.toLowerCase();
570
+ const field = this.props.embedded ? 'name' : this.getEffectiveFilterField();
571
+ const onlyUpdatable = !this.props.embedded && this.state.onlyUpdatable;
572
+ const onlyBatteryProblem = !this.props.embedded && this.state.onlyBatteryProblem;
573
+ if (!filter && !onlyUpdatable && !onlyBatteryProblem) {
574
+ return devices;
575
+ }
576
+ return devices.filter(device => {
577
+ const fields = this.fieldsResolver.getValues(deviceKey(device));
578
+ if (filter &&
579
+ !String(fields[field] ?? '')
580
+ .toLowerCase()
581
+ .includes(filter)) {
582
+ return false;
397
583
  }
398
- this.modelValues.set(key, model);
399
- }
400
- else if (this.modelValues.has(key)) {
401
- this.modelValues.delete(key);
402
- }
403
- else {
404
- return;
405
- }
406
- const modelOptions = Array.from(new Set(this.modelValues.values())).sort();
407
- if (modelOptions.length !== this.state.modelOptions.length ||
408
- modelOptions.some((model, i) => model !== this.state.modelOptions[i])) {
409
- this.setState({ modelOptions });
410
- }
411
- };
584
+ if (onlyUpdatable && !fields.updateAvailable) {
585
+ return false;
586
+ }
587
+ if (onlyBatteryProblem && !fields.batteryProblem) {
588
+ return false;
589
+ }
590
+ return true;
591
+ });
592
+ }
412
593
  /** The selected filter field, falling back to `name` if the stored field is not available (e.g. no models found) */
413
594
  getEffectiveFilterField() {
414
595
  const field = this.state.filterField;
@@ -464,7 +645,7 @@ export default class DeviceList extends Communication {
464
645
  input: {
465
646
  autoComplete: 'new-password',
466
647
  endAdornment: this.state.filterText ? (React.createElement(InputAdornment, { position: "end" },
467
- React.createElement(IconButton, { tabIndex: -1, onClick: () => this.handleFilterChange(''), edge: "end" },
648
+ React.createElement(IconButton, { tabIndex: -1, onClick: () => this.handleFilterChange(''), color: "inherit", edge: "end" },
468
649
  React.createElement(Clear, null)))) : null,
469
650
  },
470
651
  htmlInput: {
@@ -529,9 +710,16 @@ export default class DeviceList extends Communication {
529
710
  }
530
711
  /** IDs of the configurable indicators the user has switched off */
531
712
  getHiddenIndicators() {
532
- return this.getConfigurableIndicators()
713
+ const hidden = this.getConfigurableIndicators()
533
714
  .filter(indicator => !this.isIndicatorVisible(indicator))
534
715
  .map(indicator => indicator.id);
716
+ // The array is handed to every card. A new array on every render would defeat the shallow
717
+ // property comparison of `DeviceCard`.
718
+ const key = hidden.join(',');
719
+ if (!this.hiddenIndicatorsCache || this.hiddenIndicatorsCache.key !== key) {
720
+ this.hiddenIndicatorsCache = { key, value: hidden };
721
+ }
722
+ return this.hiddenIndicatorsCache.value;
535
723
  }
536
724
  toggleIndicator(indicator) {
537
725
  const indicatorVisibility = {
@@ -549,7 +737,7 @@ export default class DeviceList extends Communication {
549
737
  }
550
738
  return (React.createElement(React.Fragment, null,
551
739
  React.createElement(Tooltip, { title: getTranslation('indicatorsTooltip'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
552
- React.createElement(IconButton, { size: "small", onClick: e => this.setState({ indicatorsAnchor: e.currentTarget }) },
740
+ React.createElement(IconButton, { size: "small", color: "inherit", onClick: e => this.setState({ indicatorsAnchor: e.currentTarget }) },
553
741
  React.createElement(Tune, null))),
554
742
  React.createElement(Menu, { open: !!this.state.indicatorsAnchor, anchorEl: this.state.indicatorsAnchor, onClose: () => this.setState({ indicatorsAnchor: null }) },
555
743
  React.createElement(ListSubheader, { style: { lineHeight: '32px' } }, getTranslation('indicatorsTitle')),
@@ -585,35 +773,6 @@ export default class DeviceList extends Communication {
585
773
  const emptyStyle = {
586
774
  padding: 25,
587
775
  };
588
- if ((this.props.triggerLoad || 0) !== this.lastTriggerLoad) {
589
- this.lastTriggerLoad = this.props.triggerLoad || 0;
590
- setTimeout(() => this.loadDeviceList(), 50);
591
- }
592
- // if instance changed
593
- if (this.lastInstance !== this.state.selectedInstance) {
594
- this.lastInstance = this.state.selectedInstance;
595
- setTimeout(async () => {
596
- // The indicator visibility is stored per instance
597
- this.setState({
598
- indicatorVisibility: DeviceList.loadIndicatorVisibility(this.state.selectedInstance),
599
- indicatorsAnchor: null,
600
- });
601
- if (this.state.selectedInstance) {
602
- try {
603
- await this.loadAllData();
604
- }
605
- catch (error) {
606
- console.error(error);
607
- }
608
- }
609
- else {
610
- this.loadDeviceList();
611
- }
612
- }, 50);
613
- }
614
- if (this.props.selectedInstance && this.props.selectedInstance !== this.state.selectedInstance) {
615
- setTimeout(() => this.setState({ selectedInstance: this.props.selectedInstance }), 50);
616
- }
617
776
  const deviceGroups = [];
618
777
  const showRootPage = !this.props.embedded && this.props.selectedInstance === undefined && !this.state.selectedInstance;
619
778
  let list;
@@ -680,21 +839,37 @@ export default class DeviceList extends Communication {
680
839
  }
681
840
  }
682
841
  if (this.state.selectedInstance) {
842
+ const selectedInstance = this.state.selectedInstance;
683
843
  const hiddenIndicators = this.getHiddenIndicators();
684
- list = filteredDevices.map(device => (React.createElement(DeviceCard, { key: JSON.stringify(device.id), smallCards: this.props.smallCards ?? this.state.instanceInfo?.smallCards, filter: this.props.embedded ? this.props.filter : this.state.filter, alive: !!this.state.alive, id: device.id, identifierLabel: this.state.instanceInfo?.identifierLabel ?? 'ID', device: device, instanceId: this.state.selectedInstance, uploadImagesToInstance: this.props.uploadImagesToInstance, deviceHandler: this.deviceHandler, controlHandler: this.controlHandler, controlStateHandler: this.controlStateHandler, socket: this.props.socket, themeName: this.props.themeName, themeType: this.props.themeType, theme: this.props.theme, isFloatComma: this.props.isFloatComma, dateFormat: this.props.dateFormat, onlyUpdatable: this.state.onlyUpdatable, onlyBatteryProblem: this.state.onlyBatteryProblem, filterField: this.props.embedded ? undefined : this.getEffectiveFilterField(), hiddenIndicators: hiddenIndicators, onModel: this.reportModel })));
844
+ const smallCards = this.props.smallCards ?? this.state.instanceInfo?.smallCards;
845
+ const identifierLabel = this.state.instanceInfo?.identifierLabel ?? 'ID';
846
+ const cardWidth = smallCards ? SMALL_CARD_WIDTH : CARD_WIDTH;
847
+ const cardMinHeight = smallCards ? SMALL_CARD_MIN_HEIGHT : CARD_MIN_HEIGHT;
848
+ const cardMargin = smallCards ? SMALL_CARD_MARGIN : CARD_MARGIN;
849
+ const visibleDevices = this.filterDevices(filteredDevices);
850
+ // Every card is wrapped in a `LazyRender`: the placeholder always occupies the full
851
+ // footprint of a card, but the card itself is only built while it is near the visible
852
+ // area. Without that, 80 devices mean thousands of DOM nodes and several hundred MUI
853
+ // tooltips and buttons at once - which is what makes Safari (and especially iPadOS)
854
+ // hang and finally kill the tab.
855
+ list = visibleDevices.map(device => {
856
+ const key = deviceKey(device);
857
+ return (React.createElement(LazyRender, { key: key, observer: this.lazyObserver, width: cardWidth, minHeight: cardMinHeight, margin: cardMargin }, () => (React.createElement(DeviceCard, { fillContainer: true, smallCards: smallCards, alive: !!this.state.alive, id: device.id, identifierLabel: identifierLabel, device: device, fields: this.fieldsResolver.getValues(key), stateOrObjectHandler: this.stateOrObjectHandler, instanceId: selectedInstance, uploadImagesToInstance: this.props.uploadImagesToInstance, deviceHandler: this.deviceHandler, controlHandler: this.controlHandler, controlStateHandler: this.controlStateHandler, socket: this.props.socket, themeName: this.props.themeName, themeType: this.props.themeType, theme: this.props.theme, isFloatComma: this.props.isFloatComma, dateFormat: this.props.dateFormat, hiddenIndicators: hiddenIndicators }))));
858
+ });
685
859
  if (this.state.loading) {
686
- const skeletons = (this.state.totalDevices ?? list.length + 1) - list.length;
860
+ // How many devices are still missing - deliberately not derived from `list`,
861
+ // which only contains the devices that pass the filter
862
+ const loaded = this.state.devices.length;
863
+ const missing = (this.state.totalDevices ?? loaded + 1) - loaded;
864
+ const skeletons = Math.min(missing, MAX_SKELETONS);
687
865
  for (let i = 0; i < skeletons; i++) {
688
- list.push(React.createElement(DeviceCardSkeleton, { key: `skeleton-${i}`, smallCards: this.props.smallCards ?? this.state.instanceInfo?.smallCards, theme: this.props.theme }));
866
+ list.push(React.createElement(DeviceCardSkeleton, { key: `skeleton-${i}`, smallCards: smallCards, theme: this.props.theme }));
689
867
  }
690
868
  }
691
- else if (this.state.devices.length > 0) {
692
- list.push(React.createElement(Box, { key: "filtered", sx: {
693
- padding: '25px',
694
- '&:not(:first-child)': {
695
- display: 'none',
696
- },
697
- } },
869
+ else if (!visibleDevices.length && this.state.devices.length > 0) {
870
+ // The filter is applied here now, so the list knows that nothing is left instead
871
+ // of having to hide the message with a CSS rule
872
+ list.push(React.createElement("div", { style: emptyStyle, key: "filtered" },
698
873
  React.createElement("span", null, getTranslation('allDevicesFilteredOut'))));
699
874
  }
700
875
  }
@@ -711,8 +886,7 @@ export default class DeviceList extends Communication {
711
886
  this.state.apiVersionError ? React.createElement("div", null, I18n.t('apiVersionError')) : list));
712
887
  }
713
888
  return (React.createElement("div", { style: { width: '100%', height: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' } },
714
- React.createElement(Toolbar, { variant: "dense", style: {
715
- backgroundColor: '#777',
889
+ React.createElement(Toolbar, { variant: "dense", sx: TOOLBAR_SX, style: {
716
890
  display: 'flex',
717
891
  flexWrap: 'wrap',
718
892
  rowGap: 4,
@@ -724,17 +898,17 @@ export default class DeviceList extends Communication {
724
898
  this.state.dmInstances &&
725
899
  this.state.selectedInstance ? (React.createElement("div", { style: { display: 'flex', alignItems: 'center', gap: 8 } },
726
900
  Object.keys(this.state.dmInstances).length > 1 ? (React.createElement(Tooltip, { title: getTranslation('backToInstancesList'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
727
- React.createElement(IconButton, { onClick: () => this.backToInstancesList(), size: "small" },
901
+ React.createElement(IconButton, { onClick: () => this.backToInstancesList(), color: "inherit", size: "small" },
728
902
  React.createElement(ArrowBack, null)))) : null,
729
903
  this.state.dmInstances[this.state.selectedInstance]?.icon ? (React.createElement(Icon, { src: this.state.dmInstances[this.state.selectedInstance].icon, style: { width: 24, height: 24 } })) : null,
730
904
  React.createElement("span", { style: { marginRight: 8 } }, this.state.selectedInstance))) : null,
731
905
  this.props.selectedInstance === undefined && !this.state.selectedInstance ? (React.createElement(Tooltip, { title: getTranslation('refreshInstanceList'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
732
906
  React.createElement("span", null,
733
- React.createElement(IconButton, { onClick: () => this.refreshInstanceList(), disabled: !this.state.dmInstances, size: "small" },
907
+ React.createElement(IconButton, { onClick: () => this.refreshInstanceList(), disabled: !this.state.dmInstances, color: "inherit", size: "small" },
734
908
  React.createElement(Refresh, null))))) : null,
735
909
  this.state.selectedInstance ? (React.createElement(Tooltip, { title: getTranslation('refreshTooltip'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
736
910
  React.createElement("span", null,
737
- React.createElement(IconButton, { onClick: () => this.loadAllData(), disabled: !this.state.alive || this.state.apiVersionError, size: "small" },
911
+ React.createElement(IconButton, { onClick: () => this.loadAllData(), disabled: !this.state.alive || this.state.apiVersionError, color: "inherit", size: "small" },
738
912
  React.createElement(Refresh, null))))) : null,
739
913
  !this.state.apiVersionError && this.state.alive && this.renderInstanceActions(),
740
914
  !this.state.apiVersionError && this.state.alive && this.state.instanceInfo?.indicators?.length ? (React.createElement(StatusIndicators, { indicators: this.state.instanceInfo.indicators.filter(indicator => this.isIndicatorVisible(indicator)), theme: this.props.theme, stateOrObjectHandler: this.stateOrObjectHandler, resolveAction: this.resolveInstanceIndicatorAction, defaultColor: "#fff", style: { marginLeft: 20 } })) : null,
@@ -743,7 +917,7 @@ export default class DeviceList extends Communication {
743
917
  !this.state.apiVersionError &&
744
918
  this.state.alive &&
745
919
  this.state.devices.some(device => device.update) ? (React.createElement(Tooltip, { title: getTranslation('onlyUpdatableTooltip'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
746
- React.createElement(IconButton, { color: this.state.onlyUpdatable ? 'primary' : 'default', onClick: () => {
920
+ React.createElement(IconButton, { color: "inherit", sx: this.state.onlyUpdatable ? TOOLBAR_TOGGLE_ACTIVE_SX : undefined, onClick: () => {
747
921
  const onlyUpdatable = !this.state.onlyUpdatable;
748
922
  this.setState({ onlyUpdatable });
749
923
  window.localStorage.setItem('dm_onlyUpdatable', onlyUpdatable ? 'true' : 'false');
@@ -752,7 +926,7 @@ export default class DeviceList extends Communication {
752
926
  !this.state.apiVersionError &&
753
927
  this.state.alive &&
754
928
  this.state.devices.some(device => hasBatteryStatus(device.status)) ? (React.createElement(Tooltip, { title: getTranslation('onlyBatteryProblemTooltip'), slotProps: { popper: { sx: { pointerEvents: 'none' } } } },
755
- React.createElement(IconButton, { color: this.state.onlyBatteryProblem ? 'primary' : 'default', onClick: () => {
929
+ React.createElement(IconButton, { color: "inherit", sx: this.state.onlyBatteryProblem ? TOOLBAR_TOGGLE_ACTIVE_SX : undefined, onClick: () => {
756
930
  const onlyBatteryProblem = !this.state.onlyBatteryProblem;
757
931
  this.setState({ onlyBatteryProblem });
758
932
  window.localStorage.setItem('dm_onlyBatteryProblem', onlyBatteryProblem ? 'true' : 'false');
@@ -760,16 +934,15 @@ export default class DeviceList extends Communication {
760
934
  React.createElement(BatteryAlert, null)))) : null,
761
935
  !this.state.apiVersionError && this.state.alive ? this.renderIndicatorSettings() : null,
762
936
  !this.state.apiVersionError && this.state.alive ? (React.createElement("div", { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
763
- React.createElement(FilterAlt, { style: { color: '#fff' } }),
937
+ React.createElement(FilterAlt, null),
764
938
  this.renderFilterFields(),
765
939
  this.renderFilterValue())) : null,
766
940
  React.createElement(Typography, { sx: { display: { xs: 'none', md: 'block' } }, style: {
767
941
  marginLeft: 16,
768
942
  fontWeight: 'bold',
769
943
  whiteSpace: 'nowrap',
770
- color: '#fff',
771
- } }, "Config-Manager")),
772
- React.createElement("div", { style: {
944
+ } }, I18n.t('configManager'))),
945
+ React.createElement("div", { ref: this.containerRef, style: {
773
946
  width: '100%',
774
947
  flex: 1,
775
948
  minHeight: 0,