@ -0,0 +1,48 @@ |
|||
name: Code Formatting |
|||
|
|||
on: |
|||
pull_request: |
|||
branches: [ main, master ] |
|||
workflow_dispatch: |
|||
|
|||
jobs: |
|||
format: |
|||
runs-on: ubuntu-latest |
|||
|
|||
permissions: |
|||
contents: write |
|||
pull-requests: write |
|||
|
|||
steps: |
|||
- name: Checkout repository |
|||
uses: actions/checkout@v4 |
|||
with: |
|||
ref: ${{ github.head_ref }} |
|||
fetch-depth: 0 |
|||
token: ${{ secrets.GITHUB_TOKEN }} |
|||
|
|||
- name: Setup Node.js |
|||
uses: actions/setup-node@v4 |
|||
with: |
|||
node-version: '20' |
|||
|
|||
- name: Install Biome |
|||
run: npm install --global @biomejs/biome |
|||
|
|||
- name: Format with Biome |
|||
run: biome format --write . |
|||
|
|||
- name: Check for changes |
|||
id: git-check |
|||
run: | |
|||
git diff --quiet || echo "changes=true" >> $GITHUB_OUTPUT |
|||
|
|||
- name: Commit and push changes |
|||
if: steps.git-check.outputs.changes == 'true' |
|||
run: | |
|||
git config --global user.email "github-actions[bot]@users.noreply.github.com" |
|||
git config --global user.name "github-actions[bot]" |
|||
|
|||
git add -A |
|||
git commit -m "chore: format code" |
|||
git push |
|||
@ -113,9 +113,6 @@ importers: |
|||
rfc4648: |
|||
specifier: ^1.5.4 |
|||
version: 1.5.4 |
|||
timeago-react: |
|||
specifier: ^3.0.6 |
|||
version: 3.0.6([email protected]) |
|||
vite-plugin-node-polyfills: |
|||
specifier: ^0.23.0 |
|||
version: 0.23.0([email protected])([email protected](@types/[email protected])([email protected])([email protected])) |
|||
@ -2860,14 +2857,6 @@ packages: |
|||
[email protected]: |
|||
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-4ywnCX3iFjdp84WPK7gt8s4n0FxXbYM+xv8hYL73p83dpcMxzmO+0W4xJuxflnkWNvum5aEaqTe6LZ3lUIudjQ==} |
|||
peerDependencies: |
|||
react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} |
|||
engines: {node: '>=0.6.0'} |
|||
@ -6629,13 +6618,6 @@ snapshots: |
|||
dependencies: |
|||
readable-stream: 3.6.2 |
|||
|
|||
[email protected]([email protected]): |
|||
dependencies: |
|||
react: 19.0.0 |
|||
timeago.js: 4.0.2 |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: |
|||
dependencies: |
|||
setimmediate: 1.0.5 |
|||
|
|||
@ -0,0 +1,5 @@ |
|||
# Copyright Notice |
|||
Copyright © 2024 Meshtastic LLC. All Rights Reserved. |
|||
|
|||
## In reference to the GNU GPLv3 License terms defined in Section 7e |
|||
Images (or assets) in this directory are protected under international copyright laws and treaties. Unauthorized reproduction, distribution, modification, or use of these images in any form, commercial or otherwise, outside of official Meshtastic creative works or its Backers and Partners is strictly prohibited without prior written consent from the copyright holder (Meshtastic LLC). |
|||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 120 KiB |
@ -0,0 +1,62 @@ |
|||
import { useDevice } from "@app/core/stores/deviceStore"; |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog"; |
|||
import type { Protobuf, Types } from "@meshtastic/js"; |
|||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
import type { JSX } from "react"; |
|||
|
|||
export interface LocationResponseDialogProps { |
|||
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined; |
|||
open: boolean; |
|||
onOpenChange: () => void; |
|||
} |
|||
|
|||
export const LocationResponseDialog = ({ |
|||
location, |
|||
open, |
|||
onOpenChange, |
|||
}: LocationResponseDialogProps): JSX.Element => { |
|||
const { nodes } = useDevice(); |
|||
|
|||
const from = nodes.get(location?.from ?? 0); |
|||
const longName = |
|||
from?.user?.longName ?? |
|||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); |
|||
const shortName = |
|||
from?.user?.shortName ?? |
|||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); |
|||
|
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle> |
|||
</DialogHeader> |
|||
<DialogDescription> |
|||
<div className="ml-5 flex"> |
|||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> |
|||
<p> |
|||
Coordinates:{" "} |
|||
<a |
|||
className="text-blue-500 dark:text-blue-400" |
|||
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7}&mlon=${location?.data.longitudeI / 1e7}&layers=N`} |
|||
target="_blank" |
|||
rel="noreferrer" |
|||
> |
|||
{location?.data.latitudeI / 1e7},{" "} |
|||
{location?.data.longitudeI / 1e7} |
|||
</a> |
|||
</p> |
|||
<p>Altitude: {location?.data.altitude}m</p> |
|||
</span> |
|||
</div> |
|||
</DialogDescription> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -0,0 +1,163 @@ |
|||
import { useAppStore } from "@app/core/stores/appStore"; |
|||
import { useDevice } from "@app/core/stores/deviceStore"; |
|||
import { |
|||
Accordion, |
|||
AccordionContent, |
|||
AccordionItem, |
|||
AccordionTrigger, |
|||
} from "@components/UI/Accordion"; |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogFooter, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog"; |
|||
import { Protobuf } from "@meshtastic/js"; |
|||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
import { DeviceImage } from "../generic/DeviceImage"; |
|||
import { TimeAgo } from "../generic/TimeAgo"; |
|||
import { Uptime } from "../generic/Uptime"; |
|||
|
|||
export interface NodeDetailsDialogProps { |
|||
open: boolean; |
|||
onOpenChange: (open: boolean) => void; |
|||
} |
|||
|
|||
export const NodeDetailsDialog = ({ |
|||
open, |
|||
onOpenChange, |
|||
}: NodeDetailsDialogProps) => { |
|||
const { nodes } = useDevice(); |
|||
const { nodeNumDetails } = useAppStore(); |
|||
const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails); |
|||
|
|||
return device ? ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle> |
|||
Node Details for {device.user?.longName ?? "UNKNOWN"} ( |
|||
{device.user?.shortName ?? "UNK"}) |
|||
</DialogTitle> |
|||
</DialogHeader> |
|||
<DialogFooter> |
|||
<div className="w-full"> |
|||
<DeviceImage |
|||
className="w-32 h-32 mx-auto rounded-lg border-4 border-gray-200 dark:border-gray-800" |
|||
deviceType={ |
|||
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0] |
|||
} |
|||
/> |
|||
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> |
|||
Details: |
|||
</p> |
|||
<p> |
|||
Hardware:{" "} |
|||
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]} |
|||
</p> |
|||
<p>Node Number: {device.num}</p> |
|||
<p>Node HEX: !{numberToHexUnpadded(device.num)}</p> |
|||
<p> |
|||
Role:{" "} |
|||
{ |
|||
Protobuf.Config.Config_DeviceConfig_Role[ |
|||
device.user?.role ?? 0 |
|||
] |
|||
} |
|||
</p> |
|||
<p> |
|||
Last Heard:{" "} |
|||
{device.lastHeard === 0 ? ( |
|||
"Never" |
|||
) : ( |
|||
<TimeAgo timestamp={device.lastHeard * 1000} /> |
|||
)} |
|||
</p> |
|||
</div> |
|||
|
|||
{device.position ? ( |
|||
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> |
|||
Position: |
|||
</p> |
|||
{device.position.latitudeI && device.position.longitudeI ? ( |
|||
<p> |
|||
Coordinates:{" "} |
|||
<a |
|||
className="text-blue-500 dark:text-blue-400" |
|||
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7}&mlon=${device.position.longitudeI / 1e7}&layers=N`} |
|||
target="_blank" |
|||
rel="noreferrer" |
|||
> |
|||
{device.position.latitudeI / 1e7},{" "} |
|||
{device.position.longitudeI / 1e7} |
|||
</a> |
|||
</p> |
|||
) : null} |
|||
{device.position.altitude ? ( |
|||
<p>Altitude: {device.position.altitude}m</p> |
|||
) : null} |
|||
</div> |
|||
) : null} |
|||
|
|||
{device.deviceMetrics ? ( |
|||
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> |
|||
Device Metrics: |
|||
</p> |
|||
{device.deviceMetrics.airUtilTx ? ( |
|||
<p> |
|||
Air TX utilization:{" "} |
|||
{device.deviceMetrics.airUtilTx.toFixed(2)}% |
|||
</p> |
|||
) : null} |
|||
{device.deviceMetrics.channelUtilization ? ( |
|||
<p> |
|||
Channel utilization:{" "} |
|||
{device.deviceMetrics.channelUtilization.toFixed(2)}% |
|||
</p> |
|||
) : null} |
|||
{device.deviceMetrics.batteryLevel ? ( |
|||
<p> |
|||
Battery level:{" "} |
|||
{device.deviceMetrics.batteryLevel.toFixed(2)}% |
|||
</p> |
|||
) : null} |
|||
{device.deviceMetrics.voltage ? ( |
|||
<p>Voltage: {device.deviceMetrics.voltage.toFixed(2)}V</p> |
|||
) : null} |
|||
{device.deviceMetrics.uptimeSeconds ? ( |
|||
<p> |
|||
Uptime:{" "} |
|||
<Uptime seconds={device.deviceMetrics.uptimeSeconds} /> |
|||
</p> |
|||
) : null} |
|||
</div> |
|||
) : null} |
|||
|
|||
{device ? ( |
|||
<div className="mt-5 w-full max-w-[464px] bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> |
|||
<Accordion className="AccordionRoot" type="single" collapsible> |
|||
<AccordionItem className="AccordionItem" value="item-1"> |
|||
<AccordionTrigger> |
|||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> |
|||
All Raw Metrics: |
|||
</p> |
|||
</AccordionTrigger> |
|||
<AccordionContent className="overflow-x-scroll"> |
|||
<pre className="text-xs w-full"> |
|||
{JSON.stringify(device, null, 2)} |
|||
</pre> |
|||
</AccordionContent> |
|||
</AccordionItem> |
|||
</Accordion> |
|||
</div> |
|||
) : null} |
|||
</div> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog> |
|||
) : null; |
|||
}; |
|||
@ -0,0 +1,117 @@ |
|||
import { toast } from "@app/core/hooks/useToast"; |
|||
import { useAppStore } from "@app/core/stores/appStore"; |
|||
import { useDevice } from "@app/core/stores/deviceStore"; |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog"; |
|||
import type { Protobuf } from "@meshtastic/js"; |
|||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
import { TrashIcon } from "lucide-react"; |
|||
import type { JSX } from "react"; |
|||
import { Button } from "../UI/Button"; |
|||
|
|||
export interface NodeOptionsDialogProps { |
|||
node: Protobuf.Mesh.NodeInfo | undefined; |
|||
open: boolean; |
|||
onOpenChange: () => void; |
|||
} |
|||
|
|||
export const NodeOptionsDialog = ({ |
|||
node, |
|||
open, |
|||
onOpenChange, |
|||
}: NodeOptionsDialogProps): JSX.Element => { |
|||
const { setDialogOpen, connection, setActivePage } = useDevice(); |
|||
const { |
|||
setNodeNumToBeRemoved, |
|||
setNodeNumDetails, |
|||
setChatType, |
|||
setActiveChat, |
|||
} = useAppStore(); |
|||
const longName = |
|||
node?.user?.longName ?? |
|||
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown"); |
|||
const shortName = |
|||
node?.user?.shortName ?? |
|||
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK"); |
|||
|
|||
function handleDirectMessage() { |
|||
if (!node) return; |
|||
setChatType("direct"); |
|||
setActiveChat(node.num); |
|||
setActivePage("messages"); |
|||
} |
|||
|
|||
function handleRequestPosition() { |
|||
if (!node) return; |
|||
toast({ |
|||
title: "Requesting position, please wait...", |
|||
}); |
|||
connection?.requestPosition(node.num).then(() => |
|||
toast({ |
|||
title: "Position request sent.", |
|||
}), |
|||
); |
|||
onOpenChange(); |
|||
} |
|||
|
|||
function handleTraceroute() { |
|||
if (!node) return; |
|||
toast({ |
|||
title: "Sending Traceroute, please wait...", |
|||
}); |
|||
connection?.traceRoute(node.num).then(() => |
|||
toast({ |
|||
title: "Traceroute sent.", |
|||
}), |
|||
); |
|||
onOpenChange(); |
|||
} |
|||
|
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle> |
|||
</DialogHeader> |
|||
<div className="flex flex-col space-y-1"> |
|||
<div> |
|||
<Button onClick={handleDirectMessage}>Direct Message</Button> |
|||
</div> |
|||
<div> |
|||
<Button onClick={handleRequestPosition}>Request Position</Button> |
|||
</div> |
|||
<div> |
|||
<Button onClick={handleTraceroute}>Trace Route</Button> |
|||
</div> |
|||
<div> |
|||
<Button |
|||
key="remove" |
|||
variant="destructive" |
|||
onClick={() => { |
|||
setNodeNumToBeRemoved(node.num); |
|||
setDialogOpen("nodeRemoval", true); |
|||
}} |
|||
> |
|||
<TrashIcon /> |
|||
Remove |
|||
</Button> |
|||
</div> |
|||
<div> |
|||
<Button |
|||
onClick={() => { |
|||
setNodeNumDetails(node.num); |
|||
setDialogOpen("nodeDetails", true); |
|||
}} |
|||
> |
|||
More Details |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -0,0 +1,57 @@ |
|||
import { useDevice } from "@app/core/stores/deviceStore"; |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog"; |
|||
import type { Protobuf, Types } from "@meshtastic/js"; |
|||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
import type { JSX } from "react"; |
|||
import { TraceRoute } from "../PageComponents/Messages/TraceRoute"; |
|||
|
|||
export interface TracerouteResponseDialogProps { |
|||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined; |
|||
open: boolean; |
|||
onOpenChange: () => void; |
|||
} |
|||
|
|||
export const TracerouteResponseDialog = ({ |
|||
traceroute, |
|||
open, |
|||
onOpenChange, |
|||
}: TracerouteResponseDialogProps): JSX.Element => { |
|||
const { nodes } = useDevice(); |
|||
const route: number[] = traceroute?.data.route ?? []; |
|||
const routeBack: number[] = traceroute?.data.routeBack ?? []; |
|||
const snrTowards = traceroute?.data.snrTowards ?? []; |
|||
const snrBack = traceroute?.data.snrBack ?? []; |
|||
const from = nodes.get(traceroute?.from ?? 0); |
|||
const longName = |
|||
from?.user?.longName ?? |
|||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); |
|||
const shortName = |
|||
from?.user?.shortName ?? |
|||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); |
|||
const to = nodes.get(traceroute?.to ?? 0); |
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle> |
|||
</DialogHeader> |
|||
<DialogDescription> |
|||
<TraceRoute |
|||
route={route} |
|||
routeBack={routeBack} |
|||
from={from} |
|||
to={to} |
|||
snrTowards={snrTowards} |
|||
snrBack={snrBack} |
|||
/> |
|||
</DialogDescription> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -1,36 +1,60 @@ |
|||
import { useDevice } from "@app/core/stores/deviceStore.ts"; |
|||
import type { Protobuf } from "@meshtastic/js"; |
|||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
import type { JSX } from "react"; |
|||
|
|||
export interface TraceRouteProps { |
|||
from?: Protobuf.Mesh.NodeInfo; |
|||
to?: Protobuf.Mesh.NodeInfo; |
|||
route: Array<number>; |
|||
routeBack?: Array<number>; |
|||
snrTowards?: Array<number>; |
|||
snrBack?: Array<number>; |
|||
} |
|||
|
|||
export const TraceRoute = ({ |
|||
from, |
|||
to, |
|||
route, |
|||
routeBack, |
|||
snrTowards, |
|||
snrBack, |
|||
}: TraceRouteProps): JSX.Element => { |
|||
const { nodes } = useDevice(); |
|||
|
|||
return route.length === 0 ? ( |
|||
return ( |
|||
<div className="ml-5 flex"> |
|||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> |
|||
{to?.user?.longName}↔{from?.user?.longName} |
|||
</span> |
|||
</div> |
|||
) : ( |
|||
<div className="ml-5 flex"> |
|||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> |
|||
{to?.user?.longName}↔ |
|||
{route.map((hop) => { |
|||
const node = nodes.get(hop); |
|||
return `${node?.user?.longName ?? (node?.num ? numberToHexUnpadded(node.num) : "Unknown")}↔`; |
|||
})} |
|||
<p className="font-semibold">Route to destination:</p> |
|||
<p>{to?.user?.longName}</p> |
|||
<p>↓ {snrTowards?.[0] ? snrTowards[0] : "??"}dB</p> |
|||
{route.map((hop, i) => ( |
|||
<span key={nodes.get(hop)?.num}> |
|||
<p> |
|||
{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`} |
|||
</p> |
|||
<p>↓ {snrTowards?.[i + 1] ? snrTowards[i + 1] : "??"}dB</p> |
|||
</span> |
|||
))} |
|||
{from?.user?.longName} |
|||
</span> |
|||
{routeBack ? ( |
|||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> |
|||
<p className="font-semibold">Route back:</p> |
|||
<p>{from?.user?.longName}</p> |
|||
<p>↓ {snrBack?.[0] ? snrBack[0] : "??"}dB</p> |
|||
{routeBack.map((hop, i) => ( |
|||
<span key={nodes.get(hop)?.num}> |
|||
<p> |
|||
{nodes.get(hop)?.user?.longName ?? |
|||
`!${numberToHexUnpadded(hop)}`} |
|||
</p> |
|||
<p>↓ {snrBack?.[i + 1] ? snrBack[i + 1] : "??"}dB</p> |
|||
</span> |
|||
))} |
|||
{to?.user?.longName} |
|||
</span> |
|||
) : null} |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
@ -0,0 +1,44 @@ |
|||
import { cn } from "@core/utils/cn.ts"; |
|||
import * as AccordionPrimitive from "@radix-ui/react-accordion"; |
|||
import { ChevronDownIcon } from "lucide-react"; |
|||
import { type ComponentRef, forwardRef } from "react"; |
|||
|
|||
export const Accordion = AccordionPrimitive.Root; |
|||
|
|||
export const AccordionHeader = AccordionPrimitive.Header; |
|||
|
|||
export const AccordionItem = AccordionPrimitive.Item; |
|||
|
|||
export const AccordionTrigger = forwardRef< |
|||
ComponentRef<typeof AccordionPrimitive.Trigger>, |
|||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> |
|||
>(({ className, ...props }, ref) => ( |
|||
<AccordionPrimitive.Trigger |
|||
ref={ref} |
|||
className={cn( |
|||
"flex justify-between items-center w-full p-4 border-b border-gray-200 dark:border-gray-800 group", |
|||
className, |
|||
)} |
|||
{...props} |
|||
> |
|||
{props.children} |
|||
<ChevronDownIcon |
|||
className="h-5 w-5 transition-transform duration-300 group-data-[state=open]:rotate-180" |
|||
aria-hidden |
|||
/> |
|||
</AccordionPrimitive.Trigger> |
|||
)); |
|||
|
|||
export const AccordionContent = forwardRef< |
|||
ComponentRef<typeof AccordionPrimitive.Content>, |
|||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> |
|||
>(({ className, ...props }, ref) => ( |
|||
<AccordionPrimitive.Content |
|||
ref={ref} |
|||
className={cn( |
|||
"p-4 border-b border-gray-200 dark:border-gray-800", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
)); |
|||
@ -0,0 +1,53 @@ |
|||
export interface DeviceImageProps { |
|||
deviceType: string; |
|||
className?: React.HTMLAttributes<HTMLImageElement>["className"]; |
|||
} |
|||
|
|||
const hardwareModelToFilename: { [key: string]: string } = { |
|||
DIY_V1: "diy.svg", |
|||
NANO_G2_ULTRA: "nano-g2-ultra.svg", |
|||
TBEAM: "tbeam.svg", |
|||
HELTEC_HT62: "heltec-ht62-esp32c3-sx1262.svg", |
|||
RPI_PICO: "pico.svg", |
|||
T_DECK: "t-deck.svg", |
|||
HELTEC_MESH_NODE_T114: "heltec-mesh-node-t114.svg", |
|||
HELTEC_MESH_NODE_T114_CASE: "heltec-mesh-node-t114-case.svg", |
|||
HELTEC_V3: "heltec-v3.svg", |
|||
HELTEC_V3_CASE: "heltec-v3-case.svg", |
|||
HELTEC_VISION_MASTER_E213: "heltec-vision-master-e213.svg", |
|||
HELTEC_VISION_MASTER_E290: "heltec-vision-master-e290.svg", |
|||
HELTEC_VISION_MASTER_T190: "heltec-vision-master-t190.svg", |
|||
HELTEC_WIRELESS_PAPER: "heltec-wireless-paper.svg", |
|||
HELTEC_WIRELESS_PAPER_V1_0: "heltec-wireless-paper-V1_0.svg", |
|||
HELTEC_WIRELESS_TRACKER: "heltec-wireless-tracker.svg", |
|||
HELTEC_WIRELESS_TRACKER_V1_0: "heltec-wireless-tracker-V1-0.svg", |
|||
HELTEC_WSL_V3: "heltec-wsl-v3.svg", |
|||
TLORA_C6: "tlora-c6.svg", |
|||
TLORA_T3_S3: "tlora-t3s3-v1.svg", |
|||
TLORA_T3_S3_EPAPER: "tlora-t3s3-epaper.svg", |
|||
TLORA_V2: "tlora-v2-1-1_6.svg", |
|||
TLORA_V2_1_1P6: "tlora-v2-1-1_6.svg", |
|||
TLORA_V2_1_1P8: "tlora-v2-1-1_8.svg", |
|||
RAK11310: "rak11310.svg", |
|||
RAK2560: "rak2560.svg", |
|||
RAK4631: "rak4631.svg", |
|||
RAK4631_CASE: "rak4631_case.svg", |
|||
WIO_WM1110: "wio-tracker-wm1110.svg", |
|||
WM1110_DEV_KIT: "wm1110_dev_kit.svg", |
|||
STATION_G2: "station-g2.svg", |
|||
TBEAM_V0P7: "tbeam-s3-core.svg", |
|||
T_ECHO: "t-echo.svg", |
|||
TRACKER_T1000_E: "tracker-t1000-e.svg", |
|||
T_WATCH_S3: "t-watch-s3.svg", |
|||
SEEED_XIAO_S3: "seeed-xiao-s3.svg", |
|||
SENSECAP_INDICATOR: "seeed-sensecap-indicator.svg", |
|||
PROMICRO: "promicro.svg", |
|||
RPIPICOW: "rpipicow.svg", |
|||
UNKNOWN: "unknown.svg", |
|||
}; |
|||
|
|||
export const DeviceImage = ({ deviceType, className }: DeviceImageProps) => { |
|||
const getPath = (device: string) => `/devices/${device}`; |
|||
const device = hardwareModelToFilename[deviceType] || "unknown.svg"; |
|||
return <img className={className} src={getPath(device)} alt={device} />; |
|||
}; |
|||
@ -1,9 +0,0 @@ |
|||
import TimeAgoReact from "timeago-react"; |
|||
|
|||
export interface TimeAgoProps { |
|||
timestamp: number; |
|||
} |
|||
|
|||
export const TimeAgo = ({ timestamp }: TimeAgoProps): JSX.Element => { |
|||
return <TimeAgoReact datetime={timestamp} opts={{ minInterval: 10 }} />; |
|||
}; |
|||
@ -0,0 +1,66 @@ |
|||
import { |
|||
Tooltip, |
|||
TooltipContent, |
|||
TooltipPortal, |
|||
TooltipProvider, |
|||
TooltipTrigger, |
|||
} from "@radix-ui/react-tooltip"; |
|||
import type { JSX } from "react"; |
|||
|
|||
export interface TimeAgoProps { |
|||
timestamp: number; |
|||
} |
|||
|
|||
const getTimeAgo = ( |
|||
unixTimestamp: number, |
|||
locale: Intl.LocalesArgument = "en", |
|||
): string => { |
|||
const timestamp = new Date(unixTimestamp); |
|||
const diff = (new Date().getTime() - timestamp.getTime()) / 1000; |
|||
|
|||
const minutes = Math.floor(diff / 60); |
|||
const hours = Math.floor(minutes / 60); |
|||
const days = Math.floor(hours / 24); |
|||
const months = Math.floor(days / 30); |
|||
const years = Math.floor(months / 12); |
|||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); |
|||
|
|||
if (years > 0) { |
|||
return rtf.format(0 - years, "year"); |
|||
} |
|||
if (months > 0) { |
|||
return rtf.format(0 - months, "month"); |
|||
} |
|||
if (days > 0) { |
|||
return rtf.format(0 - days, "day"); |
|||
} |
|||
if (hours > 0) { |
|||
return rtf.format(0 - hours, "hour"); |
|||
} |
|||
if (minutes > 0) { |
|||
return rtf.format(0 - minutes, "minute"); |
|||
} |
|||
return rtf.format(Math.floor(0 - diff), "second"); |
|||
}; |
|||
|
|||
export const TimeAgo = ({ timestamp }: TimeAgoProps): JSX.Element => { |
|||
return ( |
|||
<TooltipProvider> |
|||
<Tooltip> |
|||
<TooltipTrigger> |
|||
<span>{getTimeAgo(timestamp)}</span> |
|||
</TooltipTrigger> |
|||
<TooltipPortal> |
|||
<TooltipContent |
|||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95" |
|||
side="top" |
|||
align="center" |
|||
sideOffset={5} |
|||
> |
|||
{new Date(timestamp).toLocaleString()} |
|||
</TooltipContent> |
|||
</TooltipPortal> |
|||
</Tooltip> |
|||
</TooltipProvider> |
|||
); |
|||
}; |
|||
@ -0,0 +1,17 @@ |
|||
import type { JSX } from "react"; |
|||
|
|||
export interface UptimeProps { |
|||
seconds: number; |
|||
} |
|||
|
|||
const getUptime = (seconds: number): string => { |
|||
const days = Math.floor(seconds / 86400); |
|||
const hours = Math.floor((seconds % 86400) / 3600); |
|||
const minutes = Math.floor(((seconds % 86400) % 3600) / 60); |
|||
const secondsLeft = Math.floor(((seconds % 86400) % 3600) % 60); |
|||
return `${days}d ${hours}h ${minutes}m ${secondsLeft}s`; |
|||
}; |
|||
|
|||
export const Uptime = ({ seconds }: UptimeProps): JSX.Element => { |
|||
return <span>{getUptime(seconds)}</span>; |
|||
}; |
|||
@ -0,0 +1,31 @@ |
|||
interface PluralForms { |
|||
one: string; |
|||
other: string; |
|||
[key: string]: string; |
|||
} |
|||
|
|||
interface FormatOptions { |
|||
locale?: string; |
|||
pluralRules?: Intl.PluralRulesOptions; |
|||
numberFormat?: Intl.NumberFormatOptions; |
|||
} |
|||
|
|||
export function formatQuantity( |
|||
value: number, |
|||
forms: PluralForms, |
|||
options: FormatOptions = {}, |
|||
) { |
|||
const { |
|||
locale = "en-US", |
|||
pluralRules: pluralOptions = { type: "cardinal" }, |
|||
numberFormat: numberOptions = {}, |
|||
} = options; |
|||
|
|||
const pluralRules = new Intl.PluralRules(locale, pluralOptions); |
|||
const numberFormat = new Intl.NumberFormat(locale, numberOptions); |
|||
|
|||
const pluralCategory = pluralRules.select(value); |
|||
const word = forms[pluralCategory]; |
|||
|
|||
return `${numberFormat.format(value)} ${word}`; |
|||
} |
|||