mirror of
https://github.com/meshtastic/web.git
synced 2026-08-07 10:23:12 -04:00
Updates for telemetry rework
This commit is contained in:
1 parent
f0c561a854
commit
d84d7d94cc
16 files changed
+994
-1000
No files matched your search
+26
-26
@@ -21,51 +21,51 @@
|
|||||||
"homepage": "https://meshtastic.org",
|
"homepage": "https://meshtastic.org",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emeraldpay/hashicon-react": "^0.5.2",
|
"@emeraldpay/hashicon-react": "^0.5.2",
|
||||||
"@meshtastic/eslint-config": "^1.0.7",
|
"@meshtastic/eslint-config": "^1.0.8",
|
||||||
"@meshtastic/meshtasticjs": "^0.6.54",
|
"@meshtastic/meshtasticjs": "^0.6.55",
|
||||||
"@reduxjs/toolkit": "^1.8.0",
|
"@reduxjs/toolkit": "^1.8.1",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"framer-motion": "^6.2.8",
|
"framer-motion": "^6.2.10",
|
||||||
"mapbox-gl": "^2.7.1",
|
"mapbox-gl": "^2.8.0",
|
||||||
"prettier": "^2.6.1",
|
"prettier": "^2.6.2",
|
||||||
"react": "^17.0.2",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^18.0.0",
|
||||||
"react-error-boundary": "^3.1.4",
|
"react-error-boundary": "^3.1.4",
|
||||||
"react-flow-renderer": "^10.0.6",
|
"react-flow-renderer": "^10.1.0",
|
||||||
"react-hook-form": "^7.28.1",
|
"react-hook-form": "^7.29.0",
|
||||||
"react-icons": "^4.3.1",
|
"react-icons": "^4.3.1",
|
||||||
"react-json-pretty": "^2.2.0",
|
"react-json-pretty": "^2.2.0",
|
||||||
"react-multi-select-component": "^4.2.3",
|
"react-multi-select-component": "^4.2.4",
|
||||||
"react-redux": "^7.2.6",
|
"react-redux": "^7.2.8",
|
||||||
"react-use-clipboard": "^1.0.7",
|
"react-use-clipboard": "^1.0.8",
|
||||||
"rfc4648": "^1.5.1",
|
"rfc4648": "^1.5.1",
|
||||||
"swr": "^1.2.2",
|
"swr": "^1.3.0",
|
||||||
"timeago-react": "^3.0.4",
|
"timeago-react": "^3.0.4",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"type-route": "^0.6.0",
|
"type-route": "^0.7.1",
|
||||||
"vite-plugin-environment": "^1.1.0"
|
"vite-plugin-environment": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/devtools": "^4.0.2",
|
"@hookform/devtools": "^4.1.0",
|
||||||
"@types/chrome": "^0.0.180",
|
"@types/chrome": "^0.0.181",
|
||||||
"@types/mapbox-gl": "^2.6.3",
|
"@types/mapbox-gl": "^2.6.4",
|
||||||
"@types/react": "^17.0.43",
|
"@types/react": "^18.0.3",
|
||||||
"@types/react-dom": "^17.0.14",
|
"@types/react-dom": "^18.0.0",
|
||||||
"@types/w3c-web-serial": "^1.0.2",
|
"@types/w3c-web-serial": "^1.0.2",
|
||||||
"@types/web-bluetooth": "^0.0.14",
|
"@types/web-bluetooth": "^0.0.14",
|
||||||
"@vitejs/plugin-react": "^1.2.0",
|
"@vitejs/plugin-react": "^1.3.0",
|
||||||
"autoprefixer": "^10.4.4",
|
"autoprefixer": "^10.4.4",
|
||||||
"gzipper": "^7.0.0",
|
"gzipper": "^7.1.0",
|
||||||
"postcss": "^8.4.12",
|
"postcss": "^8.4.12",
|
||||||
"rollup-plugin-visualizer": "^5.6.0",
|
"rollup-plugin-visualizer": "^5.6.0",
|
||||||
"tailwindcss": "^3.0.23",
|
"tailwindcss": "^3.0.24",
|
||||||
"tar": "^6.1.11",
|
"tar": "^6.1.11",
|
||||||
"typescript": "^4.6.3",
|
"typescript": "^4.6.3",
|
||||||
"unimported": "^1.19.1",
|
"unimported": "^1.19.1",
|
||||||
"vite": "^2.8.6",
|
"vite": "^2.9.1",
|
||||||
"vite-plugin-cdn-import": "^0.3.5",
|
"vite-plugin-cdn-import": "^0.3.5",
|
||||||
"vite-plugin-pwa": "^0.11.13",
|
"vite-plugin-pwa": "^0.11.13",
|
||||||
"workbox-window": "^6.5.2"
|
"workbox-window": "^6.5.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+822
-833
File diff suppressed because it is too large.
Load diff
@@ -34,6 +34,7 @@ export const SidebarOverlay = ({
|
|||||||
}
|
}
|
||||||
transition={{ type: 'just' }}
|
transition={{ type: 'just' }}
|
||||||
>
|
>
|
||||||
|
{/* @ts-expect-error */}
|
||||||
<AnimateSharedLayout>
|
<AnimateSharedLayout>
|
||||||
{/* <div className="flex gap-2 border-b border-gray-400 p-2 dark:border-gray-600"> */}
|
{/* <div className="flex gap-2 border-b border-gray-400 p-2 dark:border-gray-600"> */}
|
||||||
<div className="bg-white px-1 pt-1 drop-shadow-md dark:bg-primaryDark">
|
<div className="bg-white px-1 pt-1 drop-shadow-md dark:bg-primaryDark">
|
||||||
|
|||||||
@@ -47,12 +47,6 @@ export const LoRa = (): JSX.Element => {
|
|||||||
optionsEnum={Protobuf.Role}
|
optionsEnum={Protobuf.Role}
|
||||||
{...register('role')}
|
{...register('role')}
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
label="Send Owner Interval"
|
|
||||||
type="number"
|
|
||||||
suffix="Seconds"
|
|
||||||
{...register('sendOwnerInterval', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
label="Frequency Offset"
|
label="Frequency Offset"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const User = (): JSX.Element => {
|
|||||||
(state) => state.meshtastic.radio.hardware,
|
(state) => state.meshtastic.radio.hardware,
|
||||||
).myNodeNum;
|
).myNodeNum;
|
||||||
const node = useAppSelector((state) => state.meshtastic.nodes).find(
|
const node = useAppSelector((state) => state.meshtastic.nodes).find(
|
||||||
(node) => node.num === myNodeNum,
|
(node) => node.data.num === myNodeNum,
|
||||||
);
|
);
|
||||||
const { register, handleSubmit, formState, reset } = useForm<{
|
const { register, handleSubmit, formState, reset } = useForm<{
|
||||||
longName: string;
|
longName: string;
|
||||||
@@ -30,51 +30,47 @@ export const User = (): JSX.Element => {
|
|||||||
txPowerDbm: number;
|
txPowerDbm: number;
|
||||||
}>({
|
}>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
longName: node?.user?.longName,
|
longName: node?.data.user?.longName,
|
||||||
shortName: node?.user?.shortName,
|
shortName: node?.data.user?.shortName,
|
||||||
isLicensed: node?.user?.isLicensed,
|
isLicensed: node?.data.user?.isLicensed,
|
||||||
team: node?.user?.team,
|
team: node?.data.user?.team,
|
||||||
antAzimuth: node?.user?.antAzimuth,
|
antAzimuth: node?.data.user?.antAzimuth,
|
||||||
antGainDbi: node?.user?.antGainDbi,
|
antGainDbi: node?.data.user?.antGainDbi,
|
||||||
txPowerDbm: node?.user?.txPowerDbm,
|
txPowerDbm: node?.data.user?.txPowerDbm,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset({
|
reset({
|
||||||
longName: node?.user?.longName,
|
longName: node?.data.user?.longName,
|
||||||
shortName: node?.user?.shortName,
|
shortName: node?.data.user?.shortName,
|
||||||
isLicensed: node?.user?.isLicensed,
|
isLicensed: node?.data.user?.isLicensed,
|
||||||
team: node?.user?.team,
|
team: node?.data.user?.team,
|
||||||
});
|
});
|
||||||
}, [reset, node]);
|
}, [reset, node]);
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
const onSubmit = handleSubmit((data) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
if (node?.user) {
|
if (node?.data.user) {
|
||||||
void connection.setOwner({ ...node.user, ...data }, async () => {
|
void connection.setOwner({ ...node.data.user, ...data }, async () => {
|
||||||
reset({ ...data });
|
reset({ ...data });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
// TODO: can be removed once getUser is implemented
|
|
||||||
// dispatch(
|
|
||||||
// addUser({ ...node.user, ...{ data: { ...node.user.data, ...data } } }),
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
|
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
|
||||||
<Input label="Device ID" value={node?.user?.id} disabled />
|
<Input label="Device ID" value={node?.data.user?.id} disabled />
|
||||||
<Input label="Device Name" {...register('longName')} />
|
<Input label="Device Name" {...register('longName')} />
|
||||||
<Input label="Short Name" maxLength={3} {...register('shortName')} />
|
<Input label="Short Name" maxLength={3} {...register('shortName')} />
|
||||||
<Input
|
<Input
|
||||||
label="Mac Address"
|
label="Mac Address"
|
||||||
defaultValue={
|
defaultValue={
|
||||||
base16
|
base16
|
||||||
.stringify(node?.user?.macaddr ?? [])
|
.stringify(node?.data.user?.macaddr ?? [])
|
||||||
.match(/.{1,2}/g)
|
.match(/.{1,2}/g)
|
||||||
?.join(':') ?? ''
|
?.join(':') ?? ''
|
||||||
}
|
}
|
||||||
@@ -84,7 +80,7 @@ export const User = (): JSX.Element => {
|
|||||||
label="Hardware (DEPRECATED)"
|
label="Hardware (DEPRECATED)"
|
||||||
value={
|
value={
|
||||||
Protobuf.HardwareModel[
|
Protobuf.HardwareModel[
|
||||||
node?.user?.hwModel ?? Protobuf.HardwareModel.UNSET
|
node?.data.user?.hwModel ?? Protobuf.HardwareModel.UNSET
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
disabled
|
disabled
|
||||||
|
|||||||
@@ -50,41 +50,42 @@ export const CannedMessage = (): JSX.Element => {
|
|||||||
{...register('rotary1Enabled')}
|
{...register('rotary1Enabled')}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Encoder #1 Pin A"
|
label="Encoder Pin A"
|
||||||
type="number"
|
type="number"
|
||||||
disabled={moduleEnabled}
|
disabled={moduleEnabled}
|
||||||
{...register('rotary1PinA', { valueAsNumber: true })}
|
{...register('inputbrokerPinA', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Encoder #1 Pin B"
|
label="Encoder Pin B"
|
||||||
type="number"
|
type="number"
|
||||||
disabled={moduleEnabled}
|
disabled={moduleEnabled}
|
||||||
{...register('rotary1PinB', { valueAsNumber: true })}
|
{...register('inputbrokerPinB', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Endoer #1 Pin Press"
|
label="Endoer Pin Press"
|
||||||
type="number"
|
type="number"
|
||||||
disabled={moduleEnabled}
|
disabled={moduleEnabled}
|
||||||
{...register('rotary1PinPress', { valueAsNumber: true })}
|
{...register('inputbrokerPinPress', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Clockwise event"
|
label="Clockwise event"
|
||||||
disabled={moduleEnabled}
|
disabled={moduleEnabled}
|
||||||
optionsEnum={Protobuf.InputEventChar}
|
optionsEnum={Protobuf.InputEventChar}
|
||||||
{...register('rotary1EventCw', { valueAsNumber: true })}
|
{...register('inputbrokerEventCw', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Counter Clockwise event"
|
label="Counter Clockwise event"
|
||||||
disabled={moduleEnabled}
|
disabled={moduleEnabled}
|
||||||
optionsEnum={Protobuf.InputEventChar}
|
optionsEnum={Protobuf.InputEventChar}
|
||||||
{...register('rotary1EventCcw', { valueAsNumber: true })}
|
{...register('inputbrokerEventCcw', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Press event"
|
label="Press event"
|
||||||
disabled={moduleEnabled}
|
disabled={moduleEnabled}
|
||||||
optionsEnum={Protobuf.InputEventChar}
|
optionsEnum={Protobuf.InputEventChar}
|
||||||
{...register('rotary1EventPress', { valueAsNumber: true })}
|
{...register('inputbrokerEventPress', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox label="Up Down enabled" {...register('updown1Enabled')} />
|
||||||
<Input
|
<Input
|
||||||
label="Allow Input Source"
|
label="Allow Input Source"
|
||||||
disabled={moduleEnabled}
|
disabled={moduleEnabled}
|
||||||
|
|||||||
@@ -37,16 +37,16 @@ export const Telemetry = (): JSX.Element => {
|
|||||||
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
|
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Measurement Enabled"
|
label="Measurement Enabled"
|
||||||
{...register('telemetryModuleMeasurementEnabled')}
|
{...register('telemetryModuleEnvironmentMeasurementEnabled')}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Displayed on Screen"
|
label="Displayed on Screen"
|
||||||
{...register('telemetryModuleScreenEnabled')}
|
{...register('telemetryModuleEnvironmentScreenEnabled')}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Read Error Count Threshold"
|
label="Read Error Count Threshold"
|
||||||
type="number"
|
type="number"
|
||||||
{...register('telemetryModuleReadErrorCountThreshold', {
|
{...register('telemetryModuleEnvironmentReadErrorCountThreshold', {
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@@ -54,7 +54,7 @@ export const Telemetry = (): JSX.Element => {
|
|||||||
label="Update Interval"
|
label="Update Interval"
|
||||||
suffix="Seconds"
|
suffix="Seconds"
|
||||||
type="number"
|
type="number"
|
||||||
{...register('telemetryModuleUpdateInterval', {
|
{...register('telemetryModuleEnvironmentUpdateInterval', {
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@@ -62,23 +62,27 @@ export const Telemetry = (): JSX.Element => {
|
|||||||
label="Recovery Interval"
|
label="Recovery Interval"
|
||||||
suffix="Seconds"
|
suffix="Seconds"
|
||||||
type="number"
|
type="number"
|
||||||
{...register('telemetryModuleRecoveryInterval', {
|
{...register('telemetryModuleEnvironmentRecoveryInterval', {
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Display Farenheit"
|
label="Display Farenheit"
|
||||||
{...register('telemetryModuleDisplayFahrenheit')}
|
{...register('telemetryModuleEnvironmentDisplayFahrenheit')}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Sensor Type"
|
label="Sensor Type"
|
||||||
optionsEnum={Protobuf.RadioConfig_UserPreferences_TelemetrySensorType}
|
optionsEnum={Protobuf.RadioConfig_UserPreferences_TelemetrySensorType}
|
||||||
{...register('telemetryModuleSensorType', { valueAsNumber: true })}
|
{...register('telemetryModuleEnvironmentSensorType', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Sensor Pin"
|
label="Sensor Pin"
|
||||||
type="number"
|
type="number"
|
||||||
{...register('telemetryModuleSensorPin', { valueAsNumber: true })}
|
{...register('telemetryModuleEnvironmentSensorPin', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ import {
|
|||||||
FiBluetooth,
|
FiBluetooth,
|
||||||
FiCpu,
|
FiCpu,
|
||||||
FiGitBranch,
|
FiGitBranch,
|
||||||
FiHexagon,
|
|
||||||
FiMenu,
|
FiMenu,
|
||||||
FiMoon,
|
FiMoon,
|
||||||
FiSun,
|
FiSun,
|
||||||
FiWifi,
|
FiWifi,
|
||||||
FiX,
|
FiX,
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
|
import {
|
||||||
|
IoBatteryChargingOutline,
|
||||||
|
IoBatteryDeadOutline,
|
||||||
|
IoBatteryFullOutline,
|
||||||
|
} from 'react-icons/io5';
|
||||||
import { MdUpgrade } from 'react-icons/md';
|
import { MdUpgrade } from 'react-icons/md';
|
||||||
import { AiOutlineHeart, AiFillHeart } from 'react-icons/ai';
|
|
||||||
import {
|
import {
|
||||||
RiArrowDownLine,
|
RiArrowDownLine,
|
||||||
RiArrowUpDownLine,
|
RiArrowUpDownLine,
|
||||||
@@ -31,13 +34,6 @@ import {
|
|||||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
import { Protobuf, Types } from '@meshtastic/meshtasticjs';
|
import { Protobuf, Types } from '@meshtastic/meshtasticjs';
|
||||||
import {
|
|
||||||
IoBatteryChargingOutline,
|
|
||||||
IoBatteryDeadOutline,
|
|
||||||
IoBatteryFullOutline,
|
|
||||||
IoBatteryHalfOutline,
|
|
||||||
} from 'react-icons/io5';
|
|
||||||
import { FaTrafficLight } from 'react-icons/fa';
|
|
||||||
|
|
||||||
export const BottomNav = (): JSX.Element => {
|
export const BottomNav = (): JSX.Element => {
|
||||||
const [showVersionInfo, setShowVersionInfo] = useState(false);
|
const [showVersionInfo, setShowVersionInfo] = useState(false);
|
||||||
@@ -47,8 +43,8 @@ export const BottomNav = (): JSX.Element => {
|
|||||||
const primaryChannelSettings = useAppSelector(
|
const primaryChannelSettings = useAppSelector(
|
||||||
(state) => state.meshtastic.radio.channels,
|
(state) => state.meshtastic.radio.channels,
|
||||||
).find((channel) => channel.role === Protobuf.Channel_Role.PRIMARY)?.settings;
|
).find((channel) => channel.role === Protobuf.Channel_Role.PRIMARY)?.settings;
|
||||||
const telemetry =
|
const metrics =
|
||||||
meshtasticState.nodes[meshtasticState.radio.hardware.myNodeNum]?.telemetry;
|
meshtasticState.nodes[meshtasticState.radio.hardware.myNodeNum]?.metrics;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-20 flex justify-between divide-x divide-gray-400 border-t border-gray-400 bg-white dark:divide-gray-600 dark:border-gray-600 dark:bg-secondaryDark">
|
<div className="z-20 flex justify-between divide-x divide-gray-400 border-t border-gray-400 bg-white dark:divide-gray-600 dark:border-gray-600 dark:bg-secondaryDark">
|
||||||
@@ -89,32 +85,26 @@ export const BottomNav = (): JSX.Element => {
|
|||||||
)}
|
)}
|
||||||
<div className="truncate text-xs font-medium">
|
<div className="truncate text-xs font-medium">
|
||||||
{meshtasticState.nodes.find(
|
{meshtasticState.nodes.find(
|
||||||
(node) => node.num === meshtasticState.radio.hardware.myNodeNum,
|
(node) =>
|
||||||
)?.user?.longName ?? 'Disconnected'}
|
node.data.num === meshtasticState.radio.hardware.myNodeNum,
|
||||||
|
)?.data.user?.longName ?? 'Disconnected'}
|
||||||
</div>
|
</div>
|
||||||
</BottomNavItem>
|
</BottomNavItem>
|
||||||
<BottomNavItem tooltip="Router Heartbeat">
|
|
||||||
{telemetry?.routerHeartbeat ? (
|
|
||||||
<AiFillHeart className="h-4" />
|
|
||||||
) : (
|
|
||||||
<AiOutlineHeart className="h-4" />
|
|
||||||
)}
|
|
||||||
</BottomNavItem>
|
|
||||||
|
|
||||||
<BottomNavItem tooltip="Battery Level">
|
<BottomNavItem tooltip="Battery Level">
|
||||||
{!telemetry?.batteryLevel ? (
|
{!metrics?.batteryLevel ? (
|
||||||
<IoBatteryDeadOutline className="h-4" />
|
<IoBatteryDeadOutline className="h-4" />
|
||||||
) : telemetry?.batteryLevel > 50 ? (
|
) : metrics?.batteryLevel > 50 ? (
|
||||||
<IoBatteryFullOutline className="h-4" />
|
<IoBatteryFullOutline className="h-4" />
|
||||||
) : telemetry?.batteryLevel > 0 ? (
|
) : metrics?.batteryLevel > 0 ? (
|
||||||
<IoBatteryFullOutline className="h-4" />
|
<IoBatteryFullOutline className="h-4" />
|
||||||
) : (
|
) : (
|
||||||
<IoBatteryChargingOutline className="h-4" />
|
<IoBatteryChargingOutline className="h-4" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="truncate text-xs font-medium">
|
<div className="truncate text-xs font-medium">
|
||||||
{telemetry?.batteryLevel
|
{metrics?.batteryLevel
|
||||||
? `${telemetry?.batteryLevel}% - ${telemetry?.voltage}v`
|
? `${metrics?.batteryLevel}% - ${metrics?.voltage}v`
|
||||||
: 'No Battery'}
|
: 'No Battery'}
|
||||||
</div>
|
</div>
|
||||||
</BottomNavItem>
|
</BottomNavItem>
|
||||||
@@ -122,22 +112,22 @@ export const BottomNav = (): JSX.Element => {
|
|||||||
<BottomNavItem tooltip="Network Utilization">
|
<BottomNavItem tooltip="Network Utilization">
|
||||||
<div className="m-auto h-3 w-3 rounded-full bg-primary" />
|
<div className="m-auto h-3 w-3 rounded-full bg-primary" />
|
||||||
<div className="truncate text-xs font-medium">
|
<div className="truncate text-xs font-medium">
|
||||||
{`${telemetry?.airUtilTx ?? 0}% - Air`} |
|
{`${metrics?.airUtilTx ?? 0}% - Air`} |
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`m-auto h-3 w-3 rounded-full ${
|
className={`m-auto h-3 w-3 rounded-full ${
|
||||||
!telemetry?.channelUtilization
|
!metrics?.channelUtilization
|
||||||
? 'bg-primary'
|
? 'bg-primary'
|
||||||
: telemetry?.channelUtilization > 50
|
: metrics?.channelUtilization > 50
|
||||||
? 'bg-red-400'
|
? 'bg-red-400'
|
||||||
: telemetry?.channelUtilization > 24
|
: metrics?.channelUtilization > 24
|
||||||
? 'bg-yellow-400'
|
? 'bg-yellow-400'
|
||||||
: 'bg-primary'
|
: 'bg-primary'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="truncate text-xs font-medium">
|
<div className="truncate text-xs font-medium">
|
||||||
{`${telemetry?.channelUtilization ?? 0}% - Ch`}
|
{`${metrics?.channelUtilization ?? 0}% - Ch`}
|
||||||
</div>
|
</div>
|
||||||
</BottomNavItem>
|
</BottomNavItem>
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,6 @@ export interface Chat {
|
|||||||
messages: MessageWithAck[];
|
messages: MessageWithAck[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CurrentPosition {
|
|
||||||
latitudeI: number;
|
|
||||||
longitudeI: number;
|
|
||||||
altitude: number;
|
|
||||||
posTimestamp: number;
|
|
||||||
satsInView: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatEntries = {
|
type ChatEntries = {
|
||||||
[key in number]: Chat;
|
[key in number]: Chat;
|
||||||
};
|
};
|
||||||
@@ -33,6 +25,11 @@ interface Route {
|
|||||||
//speed stats?
|
//speed stats?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
metrics: Protobuf.DeviceMetrics;
|
||||||
|
data: Protobuf.NodeInfo;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Radio {
|
export interface Radio {
|
||||||
channels: Protobuf.Channel[];
|
channels: Protobuf.Channel[];
|
||||||
preferences: Protobuf.RadioConfig_UserPreferences;
|
preferences: Protobuf.RadioConfig_UserPreferences;
|
||||||
@@ -43,7 +40,7 @@ interface MeshtasticState {
|
|||||||
deviceStatus: Types.DeviceStatusEnum;
|
deviceStatus: Types.DeviceStatusEnum;
|
||||||
lastMeshInterraction: number;
|
lastMeshInterraction: number;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
nodes: Protobuf.NodeInfo[];
|
nodes: Node[];
|
||||||
radio: Radio;
|
radio: Radio;
|
||||||
chats: ChatEntries;
|
chats: ChatEntries;
|
||||||
logs: Types.LogEventPacket[];
|
logs: Types.LogEventPacket[];
|
||||||
@@ -87,46 +84,56 @@ export const meshtasticSlice = createSlice({
|
|||||||
},
|
},
|
||||||
addUser: (state, action: PayloadAction<Types.UserPacket>) => {
|
addUser: (state, action: PayloadAction<Types.UserPacket>) => {
|
||||||
const node = state.nodes.find(
|
const node = state.nodes.find(
|
||||||
(node) => node.num === action.payload.packet.from,
|
(node) => node.data.num === action.payload.packet.from,
|
||||||
);
|
);
|
||||||
if (node) {
|
if (node) {
|
||||||
node.user = action.payload.data;
|
node.data.user = action.payload.data;
|
||||||
if (action.payload.packet.rxTime) {
|
if (action.payload.packet.rxTime) {
|
||||||
node.lastHeard = new Date(
|
node.data.lastHeard = new Date(
|
||||||
action.payload.packet.rxTime * 1000,
|
action.payload.packet.rxTime * 1000,
|
||||||
).getTime();
|
).getTime();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.nodes.push({
|
state.nodes.push({
|
||||||
num: action.payload.packet.from,
|
data: {
|
||||||
snr: action.payload.packet.rxSnr,
|
num: action.payload.packet.from,
|
||||||
lastHeard: new Date().getTime(),
|
snr: action.payload.packet.rxSnr,
|
||||||
...action.payload.packet,
|
lastHeard: new Date().getTime(),
|
||||||
|
...action.payload.packet,
|
||||||
|
},
|
||||||
|
metrics: Protobuf.DeviceMetrics.create(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addPosition: (state, action: PayloadAction<Types.PositionPacket>) => {
|
addPosition: (state, action: PayloadAction<Types.PositionPacket>) => {
|
||||||
const node = state.nodes.find(
|
const node = state.nodes.find(
|
||||||
(node) => node.num === action.payload.packet.from,
|
(node) => node.data.num === action.payload.packet.from,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (node) {
|
if (node) {
|
||||||
node.position = action.payload.data;
|
node.data.position = action.payload.data;
|
||||||
if (action.payload.packet.rxTime) {
|
if (action.payload.packet.rxTime) {
|
||||||
node.lastHeard = new Date(
|
node.data.lastHeard = new Date(
|
||||||
action.payload.packet.rxTime * 1000,
|
action.payload.packet.rxTime * 1000,
|
||||||
).getTime();
|
).getTime();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addNode: (state, action: PayloadAction<Protobuf.NodeInfo>) => {
|
addNode: (state, action: PayloadAction<Protobuf.NodeInfo>) => {
|
||||||
const node = state.nodes.find((node) => node.num === action.payload.num);
|
const node = state.nodes.find(
|
||||||
|
(node) => node.data.num === action.payload.num,
|
||||||
|
);
|
||||||
|
|
||||||
if (node) {
|
if (node) {
|
||||||
node.lastHeard = new Date(action.payload.lastHeard * 1000).getTime();
|
node.data.lastHeard = new Date(
|
||||||
node.snr = action.payload.snr;
|
action.payload.lastHeard * 1000,
|
||||||
|
).getTime();
|
||||||
|
node.data.snr = action.payload.snr;
|
||||||
} else {
|
} else {
|
||||||
state.nodes.push(action.payload);
|
state.nodes.push({
|
||||||
|
data: action.payload,
|
||||||
|
metrics: Protobuf.DeviceMetrics.create(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addChannel: (state, action: PayloadAction<Protobuf.Channel>) => {
|
addChannel: (state, action: PayloadAction<Protobuf.Channel>) => {
|
||||||
@@ -205,9 +212,11 @@ export const meshtasticSlice = createSlice({
|
|||||||
state,
|
state,
|
||||||
action: PayloadAction<{ id: number; time: Date }>,
|
action: PayloadAction<{ id: number; time: Date }>,
|
||||||
) => {
|
) => {
|
||||||
const node = state.nodes.find((node) => node.num === action.payload.id);
|
const node = state.nodes.find(
|
||||||
|
(node) => node.data.num === action.payload.id,
|
||||||
|
);
|
||||||
if (node) {
|
if (node) {
|
||||||
node.lastHeard = action.payload.time.getTime();
|
node.data.lastHeard = action.payload.time.getTime();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addChat: (state, action: PayloadAction<number>) => {
|
addChat: (state, action: PayloadAction<number>) => {
|
||||||
|
|||||||
+4
-3
@@ -2,9 +2,9 @@ import '@app/index.css';
|
|||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { render } from 'react-dom';
|
|
||||||
|
|
||||||
import { domAnimation, LazyMotion } from 'framer-motion';
|
import { domAnimation, LazyMotion } from 'framer-motion';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
@@ -14,7 +14,9 @@ import { ReloadPrompt } from '@components/pwa/ReloadPrompt';
|
|||||||
import { RouteProvider } from '@core/router';
|
import { RouteProvider } from '@core/router';
|
||||||
import { store } from '@core/store';
|
import { store } from '@core/store';
|
||||||
|
|
||||||
render(
|
const container = document.getElementById('root') as HTMLElement;
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||||
<RouteProvider>
|
<RouteProvider>
|
||||||
@@ -27,5 +29,4 @@ render(
|
|||||||
</RouteProvider>
|
</RouteProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
document.getElementById('root'),
|
|
||||||
);
|
);
|
||||||
@@ -10,7 +10,9 @@ export const Debug = (): JSX.Element => {
|
|||||||
(state) => state.meshtastic.radio.hardware,
|
(state) => state.meshtastic.radio.hardware,
|
||||||
);
|
);
|
||||||
const node = useAppSelector((state) =>
|
const node = useAppSelector((state) =>
|
||||||
state.meshtastic.nodes.find((node) => node.num === hardwareInfo.myNodeNum),
|
state.meshtastic.nodes.find(
|
||||||
|
(node) => node.data.num === hardwareInfo.myNodeNum,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export const Info = (): JSX.Element => {
|
|||||||
(state) => state.meshtastic.radio.hardware,
|
(state) => state.meshtastic.radio.hardware,
|
||||||
);
|
);
|
||||||
const node = useAppSelector((state) =>
|
const node = useAppSelector((state) =>
|
||||||
state.meshtastic.nodes.find((node) => node.num === hardwareInfo.myNodeNum),
|
state.meshtastic.nodes.find(
|
||||||
|
(node) => node.data.num === hardwareInfo.myNodeNum,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +29,7 @@ export const Info = (): JSX.Element => {
|
|||||||
<div className="m-auto flex flex-col gap-2">
|
<div className="m-auto flex flex-col gap-2">
|
||||||
<Hashicon value={hardwareInfo.myNodeNum.toString()} size={180} />
|
<Hashicon value={hardwareInfo.myNodeNum.toString()} size={180} />
|
||||||
<div className="text-center text-lg font-medium dark:text-white">
|
<div className="text-center text-lg font-medium dark:text-white">
|
||||||
{node?.user?.longName || 'Unknown'}
|
{node?.data.user?.longName || 'Unknown'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* <img
|
{/* <img
|
||||||
|
|||||||
+12
-12
@@ -8,10 +8,10 @@ import { RiRoadMapLine } from 'react-icons/ri';
|
|||||||
import { Layout } from '@components/layout';
|
import { Layout } from '@components/layout';
|
||||||
import { MapboxProvider } from '@components/MapBox/MapboxProvider';
|
import { MapboxProvider } from '@components/MapBox/MapboxProvider';
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
import { MapContainer } from '@pages/Map/MapContainer';
|
import { MapContainer } from '@pages/Map/MapContainer';
|
||||||
import { Marker } from '@pages/Map/Marker';
|
import { Marker } from '@pages/Map/Marker';
|
||||||
import { NodeCard } from '@pages/Nodes/NodeCard';
|
import { NodeCard } from '@pages/Nodes/NodeCard';
|
||||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
|
||||||
|
|
||||||
export const Map = (): JSX.Element => {
|
export const Map = (): JSX.Element => {
|
||||||
const [selectedNode, setSelectedNode] = useState<Protobuf.NodeInfo>();
|
const [selectedNode, setSelectedNode] = useState<Protobuf.NodeInfo>();
|
||||||
@@ -25,22 +25,22 @@ export const Map = (): JSX.Element => {
|
|||||||
<MapboxProvider>
|
<MapboxProvider>
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
return (
|
return (
|
||||||
node.position && (
|
node.data.position && (
|
||||||
<Marker
|
<Marker
|
||||||
key={node.num}
|
key={node.data.num}
|
||||||
center={
|
center={
|
||||||
new mapboxgl.LngLat(
|
new mapboxgl.LngLat(
|
||||||
node.position.longitudeI / 1e7,
|
node.data.position.longitudeI / 1e7,
|
||||||
node.position.latitudeI / 1e7,
|
node.data.position.latitudeI / 1e7,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
setSelectedNode(node);
|
setSelectedNode(node.data);
|
||||||
}}
|
}}
|
||||||
className={`z-50 rounded-full border-2 bg-opacity-30 ${
|
className={`z-50 rounded-full border-2 bg-opacity-30 ${
|
||||||
node.num === selectedNode?.num
|
node.data.num === selectedNode?.num
|
||||||
? 'border-green-500 bg-green-500'
|
? 'border-green-500 bg-green-500'
|
||||||
: 'border-blue-500 bg-blue-500'
|
: 'border-blue-500 bg-blue-500'
|
||||||
}`}
|
}`}
|
||||||
@@ -66,12 +66,12 @@ export const Map = (): JSX.Element => {
|
|||||||
|
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<NodeCard
|
<NodeCard
|
||||||
key={node.num}
|
key={node.data.num}
|
||||||
node={node}
|
node={node.data}
|
||||||
isMyNode={node.num === myNodeNum}
|
isMyNode={node.data.num === myNodeNum}
|
||||||
selected={selectedNode?.num === node.num}
|
selected={selectedNode?.num === node.data.num}
|
||||||
setSelected={(): void => {
|
setSelected={(): void => {
|
||||||
setSelectedNode(node);
|
setSelectedNode(node.data);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const ChannelChat = ({
|
|||||||
(state) => state.meshtastic.radio.hardware,
|
(state) => state.meshtastic.radio.hardware,
|
||||||
).myNodeNum;
|
).myNodeNum;
|
||||||
const nodes = useAppSelector((state) => state.meshtastic.nodes).filter(
|
const nodes = useAppSelector((state) => state.meshtastic.nodes).filter(
|
||||||
(node) => node.num !== myNodeNum,
|
(node) => node.data.num !== myNodeNum,
|
||||||
);
|
);
|
||||||
const chats = useAppSelector((state) => state.meshtastic.chats);
|
const chats = useAppSelector((state) => state.meshtastic.chats);
|
||||||
const channels = useAppSelector(
|
const channels = useAppSelector(
|
||||||
@@ -80,7 +80,7 @@ export const ChannelChat = ({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
key={nodeId}
|
key={nodeId}
|
||||||
content={
|
content={
|
||||||
nodes.find((node) => node.num === nodeId)?.user
|
nodes.find((node) => node.data.num === nodeId)?.data.user
|
||||||
?.longName ?? 'UNK'
|
?.longName ?? 'UNK'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ export const Messages = (): JSX.Element => {
|
|||||||
<div className="mx-2 border-b border-gray-400 dark:border-gray-600" />
|
<div className="mx-2 border-b border-gray-400 dark:border-gray-600" />
|
||||||
)}
|
)}
|
||||||
{nodes
|
{nodes
|
||||||
.filter((node) => node.num !== myNodeNum)
|
.filter((node) => node.data.num !== myNodeNum)
|
||||||
.map((node) => (
|
.map((node) => (
|
||||||
<DmChat
|
<DmChat
|
||||||
key={node.num}
|
key={node.data.num}
|
||||||
node={node}
|
node={node.data}
|
||||||
selectedIndex={selectedChatIndex}
|
selectedIndex={selectedChatIndex}
|
||||||
setSelectedIndex={setSelectedChatIndex}
|
setSelectedIndex={setSelectedChatIndex}
|
||||||
/>
|
/>
|
||||||
@@ -81,8 +81,8 @@ export const Messages = (): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
{nodes.find((node) => node.num === selectedChatIndex)?.user
|
{nodes.find((node) => node.data.num === selectedChatIndex)
|
||||||
?.longName ?? 'Unknown'}
|
?.data.user?.longName ?? 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -105,9 +105,11 @@ export const Messages = (): JSX.Element => {
|
|||||||
: chats[selectedChatIndex].messages[index - 1].message
|
: chats[selectedChatIndex].messages[index - 1].message
|
||||||
.packet.from === message.message.packet.from
|
.packet.from === message.message.packet.from
|
||||||
}
|
}
|
||||||
sender={nodes.find(
|
sender={
|
||||||
(node) => node.num === message.message.packet.from,
|
nodes.find(
|
||||||
)}
|
(node) => node.data.num === message.message.packet.from,
|
||||||
|
)?.data
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+17
-14
@@ -37,8 +37,8 @@ export const Nodes = (): JSX.Element => {
|
|||||||
|
|
||||||
nodes.map((node, index) => {
|
nodes.map((node, index) => {
|
||||||
tmpNodes.push({
|
tmpNodes.push({
|
||||||
id: node.num.toString(),
|
id: node.data.num.toString(),
|
||||||
data: { label: node.user?.longName ?? `Unknown ${node.num}` },
|
data: { label: node.data.user?.longName ?? `Unknown ${node.data.num}` },
|
||||||
position: { x: index * 160 + 500, y: 100 + 500 },
|
position: { x: index * 160 + 500, y: 100 + 500 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -50,7 +50,7 @@ export const Nodes = (): JSX.Element => {
|
|||||||
const tmpEdges: Edge[] = [];
|
const tmpEdges: Edge[] = [];
|
||||||
|
|
||||||
nodes.map((node, index) => {
|
nodes.map((node, index) => {
|
||||||
if (node.num === myNodeNum) {
|
if (node.data.num === myNodeNum) {
|
||||||
tmpEdges.push({
|
tmpEdges.push({
|
||||||
id: `e${1}-${myNodeNum}`,
|
id: `e${1}-${myNodeNum}`,
|
||||||
source: '1',
|
source: '1',
|
||||||
@@ -85,17 +85,17 @@ export const Nodes = (): JSX.Element => {
|
|||||||
<>
|
<>
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={node.num}
|
key={node.data.num}
|
||||||
selected={node.num === selected}
|
selected={node.data.num === selected}
|
||||||
setSelected={(): void => {
|
setSelected={(): void => {
|
||||||
setSelected(node.num);
|
setSelected(node.data.num);
|
||||||
}}
|
}}
|
||||||
actions={
|
actions={
|
||||||
<IconButton
|
<IconButton
|
||||||
nested
|
nested
|
||||||
onClick={(e): void => {
|
onClick={(e): void => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelected(node.num);
|
setSelected(node.data.num);
|
||||||
}}
|
}}
|
||||||
icon={<FiSettings />}
|
icon={<FiSettings />}
|
||||||
/>
|
/>
|
||||||
@@ -103,7 +103,7 @@ export const Nodes = (): JSX.Element => {
|
|||||||
>
|
>
|
||||||
<div className="flex dark:text-white">
|
<div className="flex dark:text-white">
|
||||||
<div className="relative m-auto">
|
<div className="relative m-auto">
|
||||||
{node.num === myNodeNum && (
|
{node.data.num === myNodeNum && (
|
||||||
<Tooltip content="Your Node">
|
<Tooltip content="Your Node">
|
||||||
<m.div
|
<m.div
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
@@ -113,15 +113,18 @@ export const Nodes = (): JSX.Element => {
|
|||||||
</m.div>
|
</m.div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Hashicon value={node.num.toString()} size={32} />
|
<Hashicon value={node.data.num.toString()} size={32} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="my-auto mr-auto text-xs font-semibold dark:text-gray-400">
|
<div className="my-auto mr-auto text-xs font-semibold dark:text-gray-400">
|
||||||
{node.lastHeard
|
{node.data.lastHeard
|
||||||
? new Date(node.lastHeard).toLocaleTimeString(undefined, {
|
? new Date(node.data.lastHeard).toLocaleTimeString(
|
||||||
hour: '2-digit',
|
undefined,
|
||||||
minute: '2-digit',
|
{
|
||||||
})
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
},
|
||||||
|
)
|
||||||
: 'Never'}
|
: 'Never'}
|
||||||
</div>
|
</div>
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
Reference in new issue
Block a user