From aba08d9f126979495fc86480b73f4d50f5bdef19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Donk=C3=B3?= Date: Fri, 13 Sep 2024 02:49:20 +0200 Subject: [PATCH 1/7] perf: keep position objects unless content changes --- src/model/uav.ts | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/model/uav.ts b/src/model/uav.ts index 8a52342e..a9fdcc8c 100644 --- a/src/model/uav.ts +++ b/src/model/uav.ts @@ -7,6 +7,8 @@ import { type UAVStatusInfo } from 'flockwave-spec'; import { Base64 } from 'js-base64'; import isEqual from 'lodash-es/isEqual'; import isNil from 'lodash-es/isNil'; +import memoizeOne from 'memoize-one'; +import { shallowEqual } from 'react-redux'; import { type StoredUAV } from '~/features/uavs/types'; import { type ErrorCode } from '~/flockwave/errors'; @@ -58,6 +60,10 @@ export default class UAV { velocity?: VelocityNED; rssi: number[]; + // TODO: This should be unnecessary if we can ensure that no mutation happens + // to the output later on, thus the object spread can be avoided. + _positionMemoizer: (position: GPSPosition) => GPSPosition; + /** * Constructor. * @@ -94,6 +100,10 @@ export default class UAV { this.mode = undefined; this.velocity = undefined; this.rssi = []; + + this._positionMemoizer = memoizeOne( + (position) => ({ ...position }) + ); } /** @@ -205,6 +215,26 @@ export default class UAV { return !isNil(this.localPosition); } + /** + * Returns the position object if it is available, `undefined` otherwise. + */ + get position(): GPSPosition | undefined { + /* Null Island is treated as "no position info" */ + return this._position?.lat && this._position?.lon + ? this._positionMemoizer(this._position) + : undefined; + } + + /** + * Replaces the position object of the UAV if the new value is actually + * different from the current one. + */ + set position(value) { + if (!shallowEqual(this._position, value)) { + this._position = value; + } + } + /** * Handles the status information related to a single UAV from an UAV-INF * message. @@ -239,7 +269,7 @@ export default class UAV { } if (position) { - this._position = { + this.position = { lat: position[0] / 1e7, lon: position[1] / 1e7, amsl: isNil(position[2]) ? undefined : position[2] / 1e3, @@ -350,11 +380,6 @@ export default class UAV { * used in a Redux store. */ toJSON(): StoredUAV { - /* Null Island is treated as "no position info" */ - const position = - this._position?.lat && this._position?.lon - ? { ...this._position } - : undefined; const localPosition = this.hasLocalPosition ? structuredClone(this.localPosition) : undefined; @@ -372,7 +397,7 @@ export default class UAV { localPosition, localVelocity: structuredClone(this.localVelocity), mode: this.mode, - position, + position: this.position, velocity: structuredClone(this.velocity), rssi: structuredClone(this.rssi), }; From 2e96c2e62f58a2ccacb4e6afd35b8d28830396bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Donk=C3=B3?= Date: Fri, 13 Sep 2024 02:57:05 +0200 Subject: [PATCH 2/7] perf: batch storage of incoming messages from UAVs --- src/features/messages/slice.ts | 36 ++++++++++++++++++++++++++++++++++ src/message-hub.js | 18 +++++++++-------- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/features/messages/slice.ts b/src/features/messages/slice.ts index 2bcde3ae..db207a6f 100644 --- a/src/features/messages/slice.ts +++ b/src/features/messages/slice.ts @@ -92,6 +92,41 @@ const { actions, reducer } = createSlice({ (action as Record)['messageId'] = messageId; }, + batchAddInboundMessages( + state, + action: PayloadAction< + Array<{ + message: string; + refs?: number; + severity?: Severity; + uavId: string; + }> + > + ) { + const messageIds = []; + + for (const { message, refs, severity, uavId } of action.payload) { + messageIds.push( + addMessage( + state, + { + type: MessageType.INBOUND, + author: uavId, + date: Date.now(), + raw: true, + recipient: 'Operator', + severity, + body: message, + }, + uavId, + refs + ) + ); + } + + (action as Record)['messageIds'] = messageIds; + }, + addOutboundMessage( state, action: PayloadAction<{ message: string; uavId?: string }> @@ -169,6 +204,7 @@ export const { addInboundMessage, addOutboundMessage, addErrorMessage, + batchAddInboundMessages, clearMessagesOfUAVById, updateProgressByMessageId, } = actions; diff --git a/src/message-hub.js b/src/message-hub.js index 161ec477..d6163f9f 100644 --- a/src/message-hub.js +++ b/src/message-hub.js @@ -20,7 +20,7 @@ import { } from './model/connections'; import { handleObjectDeletionMessage } from './model/objects'; -import { addInboundMessage } from './features/messages/slice'; +import { batchAddInboundMessages } from './features/messages/slice'; import { showError, showNotification } from './features/snackbar/actions'; import { semanticsFromSeverity } from './features/snackbar/utils'; @@ -59,6 +59,8 @@ messageHub.registerNotificationHandlers({ 'SYS-MSG': (message) => { if (message.body && Array.isArray(message.body.items)) { batch(() => { + const fromUAV = []; + for (const item of message.body.items) { if (isEmpty(item.sender)) { // This message came directly from the server so we show it as a @@ -72,15 +74,15 @@ messageHub.registerNotificationHandlers({ } else { // This message probably came from a UAV so let's add it to the // list of messages received from the UAV - dispatch( - addInboundMessage({ - message: item.message, - uavId: item.sender, - severity: item.severity, - }) - ); + fromUAV.push({ + message: item.message, + uavId: item.sender, + severity: item.severity, + }); } } + + dispatch(batchAddInboundMessages(fromUAV)); }); } }, From 9edcb70ab3abfe5a7d7652df003288b85732ae0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Donk=C3=B3?= Date: Fri, 27 Sep 2024 13:06:43 +0200 Subject: [PATCH 3/7] WIP: feat: enable range selection in the UAVs list view --- src/views/uavs/DroneStatusLine.jsx | 1 + src/views/uavs/UAVList.jsx | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/views/uavs/DroneStatusLine.jsx b/src/views/uavs/DroneStatusLine.jsx index d3bd88de..98e4014e 100644 --- a/src/views/uavs/DroneStatusLine.jsx +++ b/src/views/uavs/DroneStatusLine.jsx @@ -60,6 +60,7 @@ const useStyles = makeStyles( fontVariantNumeric: 'lining-nums tabular-nums', marginTop: [-2, '!important'], marginBottom: [-4, '!important'], + userSelect: 'none', whiteSpace: 'pre', }, gone: { diff --git a/src/views/uavs/UAVList.jsx b/src/views/uavs/UAVList.jsx index 872edd69..83efa61e 100644 --- a/src/views/uavs/UAVList.jsx +++ b/src/views/uavs/UAVList.jsx @@ -223,10 +223,12 @@ const createGridItems = ( const createListItems = ( items, { + ids, isInEditMode, mappingSlotBeingEdited, onDropped, onSelectedUAV, + onSelectedUAVs, onSelectedMissionSlot, onStartEditing, selectedUAVIds, @@ -250,7 +252,25 @@ const createListItems = ( /* prettier-ignore */ onClick: isInEditMode ? (_event) => onStartEditing(missionIndex) : - uavId ? (event) => onSelectedUAV(event, uavId) : + uavId ? (event) => { + if (event.shiftKey) { + event.preventDefault(); + if (selectedUAVIds.length === 1) { + const selId = selectedUAVIds[0]; + const uIds = ids.map(([u, _m]) => u); + const selIndex = uIds.indexOf(selId); + const uavIndex = uIds.indexOf(uavId); + onSelectedUAVs( + uIds.slice( + Math.min(selIndex, uavIndex), + Math.max(selIndex, uavIndex) + 1 + ) + ); + } + } else { + onSelectedUAV(event, uavId); + } + } : missionSlotId ? (event) => onSelectedMissionSlot(event, missionSlotId) : undefined, onDrop: onDropped ? onDropped(missionIndex) : undefined, @@ -303,7 +323,7 @@ const UAVListSection = ({ <> - {itemFactory(ids, itemFactoryOptions)} + {itemFactory(ids, { ...itemFactoryOptions, ids })} ); @@ -329,6 +349,7 @@ const UAVListPresentation = ({ onEditMappingSlot, onMappingAdjusted, onSelectUAV, + onSelectUAVs, onSelectMissionSlot, onSelectSection, selectedUAVIds, @@ -363,6 +384,7 @@ const UAVListPresentation = ({ mappingSlotBeingEdited, onDropped: editingMapping && onDropped, onSelectedUAV: onSelectUAV, + onSelectedUAVs: onSelectUAVs, onSelectedMissionSlot: onSelectMissionSlot, onStartEditing: onEditMappingSlot, selectedUAVIds, @@ -580,6 +602,7 @@ const UAVList = connect( getSelection: getSelectedUAVIds, setSelection: setSelectedUAVIds, }), + onSelectUAVs: setSelectedUAVIds, onSelectMissionSlot: createSelectionHandlerThunk({ getSelection: getSelectedMissionSlotIds, setSelection: setSelectedMissionSlotIds, From abcf69b13f7f53d375e49dd8d3830b0e4d464d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Donk=C3=B3?= Date: Fri, 27 Sep 2024 13:50:18 +0200 Subject: [PATCH 4/7] WIP: feat: add a layer for visualizing RSSI on the map --- src/features/uavs/selectors.js | 10 ++++ src/model/layers.ts | 8 ++++ src/views/map/layers/index.js | 2 + src/views/map/layers/rssi.jsx | 83 ++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 src/views/map/layers/rssi.jsx diff --git a/src/features/uavs/selectors.js b/src/features/uavs/selectors.js index f10e77a8..37608cb1 100644 --- a/src/features/uavs/selectors.js +++ b/src/features/uavs/selectors.js @@ -41,6 +41,7 @@ import { convertRGB565ToCSSNotation } from '~/flockwave/parsing'; import { globalIdToUavId } from '~/model/identifiers'; import { UAVAge } from '~/model/uav'; import { selectionForSubset } from '~/selectors/selection'; +import { selectOrdered } from '~/utils/collections'; import { euclideanDistance2D, getMeanAngle } from '~/utils/math'; import { EMPTY_ARRAY } from '~/utils/redux'; import { createDeepResultSelector } from '~/utils/selectors'; @@ -68,6 +69,15 @@ export const getUAVIdToStateMapping = (state) => state.uavs.byId; */ export const getUAVById = (state, uavId) => state.uavs.byId[uavId]; +/** + * Selector that calculates and caches the list of all the UAVs in the + * state object. + */ +export const getUAVsInOrder = createSelector( + (state) => state.uavs, + selectOrdered +); + /** * Returns the current position of the UAV with the given ID, given the current * state. diff --git a/src/model/layers.ts b/src/model/layers.ts index 63aa606d..1e79a370 100644 --- a/src/model/layers.ts +++ b/src/model/layers.ts @@ -19,6 +19,7 @@ import Map from '@material-ui/icons/Map'; import MyLocation from '@material-ui/icons/MyLocation'; import Timeline from '@material-ui/icons/Timeline'; import TrackChanges from '@material-ui/icons/TrackChanges'; +import Wifi from '@material-ui/icons/Wifi'; import type BaseLayer from 'ol/layer/Base'; import type OLLayer from 'ol/layer/Layer'; @@ -49,6 +50,8 @@ export enum LayerType { UAV_TRACE = 'uavTrace', UNAVAILABLE = 'unavailable', UNTYPED = 'untyped', + + RSSI = 'rssi', } export type Layer = { @@ -76,6 +79,7 @@ export const LayerTypes = [ LayerType.OWN_LOCATION, LayerType.GEOJSON, LayerType.HEATMAP, + LayerType.RSSI, ] as const; export const ProLayerTypes = [LayerType.IMAGE] as const; @@ -251,6 +255,10 @@ const propertiesForLayerTypes: Record< label: 'Untyped layer', icon: HelpOutline, }, + [LayerType.RSSI]: { + label: 'RSSI', + icon: Wifi, + }, } as const; /** diff --git a/src/views/map/layers/index.js b/src/views/map/layers/index.js index 22b267e6..73479437 100644 --- a/src/views/map/layers/index.js +++ b/src/views/map/layers/index.js @@ -16,6 +16,7 @@ import { TileServerLayerSettings, TileServerLayer } from './tileserver'; import { UAVsLayerSettings, UAVsLayer } from './uavs'; import { UAVTraceLayerSettings, UAVTraceLayer } from './uavtrace'; import { UntypedLayerSettings, UntypedLayer } from './untyped'; +import { RSSILayer } from './rssi'; import { LayerType } from '~/model/layers'; @@ -76,6 +77,7 @@ export const Layers = { [LayerType.UAVS]: UAVsLayer, [LayerType.UAV_TRACE]: UAVTraceLayer, [LayerType.UNTYPED]: UntypedLayer, + [LayerType.RSSI]: RSSILayer, }; export const stateObjectToLayer = (layer, props) => { diff --git a/src/views/map/layers/rssi.jsx b/src/views/map/layers/rssi.jsx new file mode 100644 index 00000000..04dc2afb --- /dev/null +++ b/src/views/map/layers/rssi.jsx @@ -0,0 +1,83 @@ +import { RegularShape, Style } from 'ol/style'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; + +import { Feature, geom, layer, source } from '@collmot/ol-react'; + +import { getUAVsInOrder } from '~/features/uavs/selectors'; +import { mapViewCoordinateFromLonLat } from '~/utils/geography'; +import { dockIdToGlobalId } from '~/model/identifiers'; +import { shadowVeryThinOutline, fill } from '~/utils/styles'; + +// === Helper functions === + +const createRSSIStyle = (rssi) => [ + new Style({ + image: new RegularShape({ + points: 4, + fill: fill(`hsl(${rssi}, 100%, 50%)`), + stroke: shadowVeryThinOutline, + radius: 15, + angle: Math.PI / 4, + }), + }), +]; + +// === A single feature representing a docking station === + +const RSSIFeature = React.memo(({ uav, ...rest }) => { + const { id, position, rssi } = uav; + + if (!position) { + return null; + } + + const style = createRSSIStyle(rssi[0]); + + return ( + + + + ); +}); + +RSSIFeature.propTypes = { + selected: PropTypes.bool, + uav: PropTypes.shape({ + id: PropTypes.string, + position: PropTypes.shape({ + lat: PropTypes.number.required, + lon: PropTypes.number.required, + }), + rssi: PropTypes.arrayOf(PropTypes.number), + }), +}; + +// === The actual layer to be rendered === + +const RSSILayerPresentation = ({ uavs, zIndex }) => ( + + + {uavs.map((uav) => ( + + ))} + + +); + +RSSILayerPresentation.propTypes = { + uavs: PropTypes.arrayOf(PropTypes.object).isRequired, + zIndex: PropTypes.number, +}; + +export const RSSILayer = connect( + // mapStateToProps + (state) => ({ + uavs: getUAVsInOrder(state), + }), + // mapDispatchToProps + null +)(RSSILayerPresentation); From 35380393fa9f597c33171497c6a37c79b8214e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Donk=C3=B3?= Date: Fri, 27 Sep 2024 18:30:38 +0200 Subject: [PATCH 5/7] feat: make the flash lights button latchable --- CHANGELOG.md | 4 ++- .../uavs/UAVOperationsButtonGroup.jsx | 32 ++++++++++++++++--- src/features/settings/constants.ts | 7 +++- src/utils/messaging.js | 3 +- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dbe8ccd..34279cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added the total number of UAVw in the UAV status summary header widget. +- Added the total number of UAVs in the UAV status summary header widget. - Added a new panel for monitoring and controlling one specific drone and its devices. @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 each drone if the server provides this information. Thanks to @mwls-sean for implementing the first version of this feature! +- Make the "Flash lights" button latchable by holding Shift while activating it. + ### Changed - When setting the start time based on a time offset from the current time, diff --git a/src/components/uavs/UAVOperationsButtonGroup.jsx b/src/components/uavs/UAVOperationsButtonGroup.jsx index 84d05fd5..36cb0492 100644 --- a/src/components/uavs/UAVOperationsButtonGroup.jsx +++ b/src/components/uavs/UAVOperationsButtonGroup.jsx @@ -1,8 +1,9 @@ import isEmpty from 'lodash-es/isEmpty'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { withTranslation } from 'react-i18next'; import { connect } from 'react-redux'; +import { useInterval } from 'react-use'; import { bindActionCreators } from '@reduxjs/toolkit'; import Button from '@material-ui/core/Button'; @@ -27,6 +28,7 @@ import Colors from '~/components/colors'; import ToolbarDivider from '~/components/ToolbarDivider'; import Bolt from '~/icons/Bolt'; +import { UAV_SIGNAL_DURATION } from '~/features/settings/constants'; import { requestRemovalOfUAVsByIds, requestRemovalOfUAVsMarkedAsGone, @@ -89,16 +91,33 @@ const UAVOperationsButtonGroup = ({ dispatch ); + const [keepFlashing, setKeepFlashing] = useState(false); + const flashLightsButtonOnClick = useCallback( + (event) => { + if (keepFlashing) { + setKeepFlashing(false); + } else if (event.shiftKey) { + setKeepFlashing(true); + flashLight(); + } else { + flashLight(); + } + }, + [flashLight, keepFlashing, setKeepFlashing] + ); + + useInterval(flashLight, keepFlashing ? UAV_SIGNAL_DURATION * 1000 : null); + const fontSize = size === 'small' ? 'small' : 'medium'; const iconSize = size; const flashLightsButton = size === 'small' ? ( @@ -107,9 +126,12 @@ const UAVOperationsButtonGroup = ({ - + ); diff --git a/src/features/settings/constants.ts b/src/features/settings/constants.ts index 2bce16f2..a2817ee9 100644 --- a/src/features/settings/constants.ts +++ b/src/features/settings/constants.ts @@ -1,4 +1,9 @@ /** - * Timeout length for broadcast mode. + * Timeout length for broadcast mode, in seconds. */ export const BROADCAST_MODE_TIMEOUT_LENGTH = 5; + +/** + * Duration of the `UAV-SIGNAL` (flash light) command, in seconds. + */ +export const UAV_SIGNAL_DURATION = 5; diff --git a/src/utils/messaging.js b/src/utils/messaging.js index 015a6c29..f0b79d86 100644 --- a/src/utils/messaging.js +++ b/src/utils/messaging.js @@ -9,6 +9,7 @@ import mapValues from 'lodash-es/mapValues'; import values from 'lodash-es/values'; import { showConfirmationDialog } from '~/features/prompt/actions'; +import { UAV_SIGNAL_DURATION } from '~/features/settings/constants'; import { shouldConfirmUAVOperation } from '~/features/settings/selectors'; import { showNotification } from '~/features/snackbar/actions'; import { MessageSemantics } from '~/features/snackbar/types'; @@ -148,7 +149,7 @@ export const flashLightOnUAVsAndHideFailures = performMassOperation({ name: 'Light signal command', mapper: (options) => ({ signals: ['light'], - duration: 5000, + duration: UAV_SIGNAL_DURATION * 1000, ...options, }), reportSuccess: false, From 07fc068342101b58b1f92bb74891222018593440 Mon Sep 17 00:00:00 2001 From: Thomas Blommaert Date: Sat, 2 Nov 2024 13:40:38 +0100 Subject: [PATCH 6/7] refactor: move Position column more to the end of the drone status line --- src/views/uavs/DroneStatusLine.jsx | 14 +++++++------- src/views/uavs/SortAndFilterHeader.jsx | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/views/uavs/DroneStatusLine.jsx b/src/views/uavs/DroneStatusLine.jsx index 7131bf26..b1f53054 100644 --- a/src/views/uavs/DroneStatusLine.jsx +++ b/src/views/uavs/DroneStatusLine.jsx @@ -169,13 +169,6 @@ const DroneStatusLine = ({ > {abbreviateGPSFixType(gpsFixType)} - {localPosition ? ( - padEnd(localCoordinateFormatter(localPosition), 25) - ) : position ? ( - padEnd(coordinateFormatter([position.lon, position.lat]), 25) - ) : ( - {padEnd('no position', 25)} - )} {!isNil(amsl) ? ( padStart(position.amsl.toFixed(1), 6) + 'm' ) : ( @@ -194,6 +187,13 @@ const DroneStatusLine = ({ {padStart(!isNil(heading) ? Math.round(heading) + '°' : '', 5)} + {localPosition ? ( + padEnd(localCoordinateFormatter(localPosition), 25) + ) : position ? ( + padEnd(coordinateFormatter([position.lon, position.lat]), 25) + ) : ( + {padEnd('no position', 25)} + )} {debugString ? ' ' + debugString : ''} diff --git a/src/views/uavs/SortAndFilterHeader.jsx b/src/views/uavs/SortAndFilterHeader.jsx index 224efc55..cc619945 100644 --- a/src/views/uavs/SortAndFilterHeader.jsx +++ b/src/views/uavs/SortAndFilterHeader.jsx @@ -200,13 +200,6 @@ const COMMON_HEADER_TEXT_PARTS = Object.freeze([ width: 40, }, }, - { - label: 'Position', - style: { - textAlign: 'left', - width: 198, - }, - }, { label: 'AMSL', sortKey: UAVSortKey.ALTITUDE_MSL, @@ -239,6 +232,13 @@ const COMMON_HEADER_TEXT_PARTS = Object.freeze([ width: 40, }, }, + { + label: 'Position', + style: { + textAlign: 'left', + width: 198, + }, + }, { label: 'Details', style: { From fe978a24c0a70dc32c7863a1e441d2274e2a2bbf Mon Sep 17 00:00:00 2001 From: Thomas Blommaert Date: Sat, 2 Nov 2024 13:41:17 +0100 Subject: [PATCH 7/7] feat: add last seen column to drone status line --- src/views/uavs/DroneStatusLine.jsx | 7 ++++++- src/views/uavs/SortAndFilterHeader.jsx | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/views/uavs/DroneStatusLine.jsx b/src/views/uavs/DroneStatusLine.jsx index b1f53054..c113d80d 100644 --- a/src/views/uavs/DroneStatusLine.jsx +++ b/src/views/uavs/DroneStatusLine.jsx @@ -29,7 +29,9 @@ import { getSemanticsForRSSI, } from '~/model/enums'; import { getPreferredCoordinateFormatter } from '~/selectors/formatting'; -import { formatCoordinateArray, formatRSSI } from '~/utils/formatting'; +import { formatCoordinateArray, formatRSSI, shortTimeAgoFormatter } from '~/utils/formatting'; + +import TimeAgo from 'react-timeago'; /** * Converts the absolute value of a heading deviation, in degrees, to the @@ -123,6 +125,7 @@ const DroneStatusLine = ({ secondaryLabel, text, textSemantics, + lastUpdated, }) => { const classes = useStyles(); const { amsl, ahl, agl } = position || {}; @@ -187,6 +190,7 @@ const DroneStatusLine = ({ {padStart(!isNil(heading) ? Math.round(heading) + '°' : '', 5)} + padStart(padEnd(shortTimeAgoFormatter(...args), 5), 6)} date={lastUpdated} /> {localPosition ? ( padEnd(localCoordinateFormatter(localPosition), 25) ) : position ? ( @@ -275,6 +279,7 @@ export default connect( mode: uav ? uav.mode : undefined, position: uav ? uav.position : undefined, rssi: uav ? uav.rssi?.[0] : undefined, + lastUpdated: uav ? uav.lastUpdated : undefined, ...statusSummarySelector(state, ownProps.id), }; }; diff --git a/src/views/uavs/SortAndFilterHeader.jsx b/src/views/uavs/SortAndFilterHeader.jsx index cc619945..25e80457 100644 --- a/src/views/uavs/SortAndFilterHeader.jsx +++ b/src/views/uavs/SortAndFilterHeader.jsx @@ -232,6 +232,13 @@ const COMMON_HEADER_TEXT_PARTS = Object.freeze([ width: 40, }, }, + { + label: 'Seen', + style:{ + textAlign: 'left', + width: 40, + } + }, { label: 'Position', style: {