mirror of
https://github.com/meshtastic/web.git
synced 2026-04-20 05:47:52 -04:00
Merge branch 'pki' into feature/security-tab
This commit is contained in:
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
@@ -1,6 +1,9 @@
|
||||
name: CI
|
||||
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -21,42 +24,3 @@ jobs:
|
||||
|
||||
- name: Build Package
|
||||
run: pnpm build
|
||||
|
||||
- name: Package Output
|
||||
run: pnpm package
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: "marvinpinto/action-automatic-releases@latest"
|
||||
with:
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
automatic_release_tag: "latest"
|
||||
prerelease: false
|
||||
files: |
|
||||
./dist/build.tar
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Buildah Build
|
||||
id: build-container
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
with:
|
||||
containerfiles: |
|
||||
./Containerfile
|
||||
image: ${{github.event.repository.full_name}}
|
||||
tags: latest ${{ github.sha }}
|
||||
oci: true
|
||||
platforms: linux/amd64, linux/arm64
|
||||
|
||||
- name: Push To Registry
|
||||
id: push-to-registry
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-container.outputs.image }}
|
||||
tags: ${{ steps.build-container.outputs.tags }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Print image url
|
||||
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
|
||||
|
||||
9
.github/workflows/pr.yml
vendored
9
.github/workflows/pr.yml
vendored
@@ -19,3 +19,12 @@ jobs:
|
||||
|
||||
- name: Build Package
|
||||
run: pnpm build
|
||||
|
||||
- name: Compress build
|
||||
run: pnpm package
|
||||
|
||||
- name: Archive compressed build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: dist/build.tar
|
||||
|
||||
55
.github/workflows/release.yml
vendored
Normal file
55
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: 'Release'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-package:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build Package
|
||||
run: pnpm build
|
||||
|
||||
- name: Package Output
|
||||
run: pnpm package
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Buildah Build
|
||||
id: build-container
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
with:
|
||||
containerfiles: |
|
||||
./Containerfile
|
||||
image: ${{github.event.repository.full_name}}
|
||||
tags: latest ${{ github.sha }}
|
||||
oci: true
|
||||
platforms: linux/amd64, linux/arm64
|
||||
|
||||
- name: Push To Registry
|
||||
id: push-to-registry
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-container.outputs.image }}
|
||||
tags: ${{ steps.build-container.outputs.tags }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Print image url
|
||||
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
|
||||
@@ -5,6 +5,7 @@ import { DeviceSelector } from "@components/DeviceSelector.js";
|
||||
import { DialogManager } from "@components/Dialog/DialogManager.js";
|
||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
|
||||
import { Toaster } from "@components/Toaster.js";
|
||||
import Footer from "@components/UI/Footer.js";
|
||||
import { ThemeController } from "@components/generic/ThemeController.js";
|
||||
import { useAppStore } from "@core/stores/appStore.js";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
@@ -40,7 +41,11 @@ export const App = (): JSX.Element => {
|
||||
<PageRouter />
|
||||
</div>
|
||||
) : (
|
||||
<Dashboard />
|
||||
<>
|
||||
<Dashboard />
|
||||
<div className="flex flex-grow" />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,14 @@ import {
|
||||
} from "react-hook-form";
|
||||
|
||||
interface DisabledBy<T> {
|
||||
fieldName: Path<T> | "always";
|
||||
fieldName: Path<T>;
|
||||
selector?: number;
|
||||
invert?: boolean;
|
||||
}
|
||||
|
||||
export interface BaseFormBuilderProps<T> {
|
||||
name: Path<T>;
|
||||
disabled?: boolean;
|
||||
disabledBy?: DisabledBy<T>[];
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -62,11 +63,14 @@ export function DynamicForm<T extends FieldValues>({
|
||||
defaultValues: defaultValues,
|
||||
});
|
||||
|
||||
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => {
|
||||
const isDisabled = (
|
||||
disabledBy?: DisabledBy<T>[],
|
||||
disabled?: boolean,
|
||||
): boolean => {
|
||||
if (disabled) return true;
|
||||
if (!disabledBy) return false;
|
||||
|
||||
return disabledBy.some((field) => {
|
||||
if (field.fieldName === "always") return true;
|
||||
const value = getValues(field.fieldName);
|
||||
if (value === "always") return true;
|
||||
if (typeof value === "boolean") return field.invert ? value : !value;
|
||||
@@ -111,7 +115,7 @@ export function DynamicForm<T extends FieldValues>({
|
||||
<DynamicFormField
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={isDisabled(field.disabledBy)}
|
||||
disabled={isDisabled(field.disabledBy, field.disabled)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
))}
|
||||
|
||||
@@ -160,48 +160,57 @@ export const Security = (): JSX.Element => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "publicKey",
|
||||
label: "Public Key",
|
||||
description:
|
||||
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
||||
disabledBy: [{ fieldName: "always" }],
|
||||
properties: {
|
||||
value: publicKey,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Admin Settings",
|
||||
description: "Settings for Admin",
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "adminChannelEnabled",
|
||||
label: "Allow Legacy Admin",
|
||||
description:
|
||||
"Allow incoming device control over the insecure legacy admin channel",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "isManaged",
|
||||
label: "Managed",
|
||||
description:
|
||||
'If true, device is considered to be "managed" by a mesh administrator via admin messages',
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "adminKey",
|
||||
label: "Admin Key",
|
||||
description:
|
||||
"The public key authorized to send admin messages to this node",
|
||||
validationText: adminKeyValidationText,
|
||||
inputChange: adminKeyInputChangeEvent,
|
||||
disabledBy: [{ fieldName: "adminChannelEnabled" }],
|
||||
properties: {
|
||||
value: adminKey,
|
||||
{
|
||||
type: "text",
|
||||
name: "publicKey",
|
||||
label: "Public Key",
|
||||
disabled: true,
|
||||
description:
|
||||
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Admin Settings",
|
||||
description: "Settings for Admin ",
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "adminChannelEnabled",
|
||||
label: "Allow Legacy Admin",
|
||||
description:
|
||||
"Allow incoming device control over the insecure legacy admin channel",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "isManaged",
|
||||
label: "Managed",
|
||||
description:
|
||||
'If true, device is considered to be "managed" by a mesh administrator via admin messages',
|
||||
},
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "adminKey",
|
||||
label: "Admin Key",
|
||||
description:
|
||||
"The public key authorized to send admin messages to this node",
|
||||
validationText: adminKeyValidationText,
|
||||
devicePSKBitCount: adminKeyBitCount,
|
||||
inputChange: adminKeyInputChangeEvent,
|
||||
selectChange: adminKeySelectChangeEvent,
|
||||
hide: !adminKeyVisible,
|
||||
buttonClick: () =>
|
||||
clickEvent(
|
||||
setAdminKey,
|
||||
adminKeyBitCount,
|
||||
setAdminKeyValidationText,
|
||||
),
|
||||
disabledBy: [{ fieldName: "adminChannelEnabled" }],
|
||||
properties: {
|
||||
value: adminKey,
|
||||
action: {
|
||||
icon: adminKeyVisible ? EyeOff : Eye,
|
||||
onClick: () => setAdminKeyVisible(!adminKeyVisible),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -68,7 +68,7 @@ export const ChannelChat = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="pl-3 pr-3 pt-3 pb-1">
|
||||
<MessageInput to={to} channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@ export const Telemetry = (): JSX.Element => {
|
||||
description: "How often to send Power data over the mesh",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
type: "toggle",
|
||||
name: "powerScreenEnabled",
|
||||
label: "Power Screen Enabled",
|
||||
description: "Enable the Power Telemetry Screen",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from "@app/core/utils/cn.js";
|
||||
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
|
||||
import Footer from "./UI/Footer";
|
||||
|
||||
export interface PageLayoutProps {
|
||||
label: string;
|
||||
@@ -18,40 +19,43 @@ export const PageLayout = ({
|
||||
children,
|
||||
}: PageLayoutProps): JSX.Element => {
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col">
|
||||
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
|
||||
<button
|
||||
type="button"
|
||||
className="pl-4 transition-all hover:text-accent md:hidden"
|
||||
>
|
||||
<AlignLeftIcon />
|
||||
</button>
|
||||
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="w-full text-lg font-medium">{label}</span>
|
||||
<div className="flex justify-end space-x-4">
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={action.icon.name}
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon />
|
||||
</button>
|
||||
))}
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col">
|
||||
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
|
||||
<button
|
||||
type="button"
|
||||
className="pl-4 transition-all hover:text-accent md:hidden"
|
||||
>
|
||||
<AlignLeftIcon />
|
||||
</button>
|
||||
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="w-full text-lg font-medium">{label}</span>
|
||||
<div className="flex justify-end space-x-4">
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={action.icon.name}
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-y-auto",
|
||||
!noPadding && "pl-3 pr-3 ",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-y-auto",
|
||||
!noPadding && "p-3",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
37
src/components/UI/Footer.tsx
Normal file
37
src/components/UI/Footer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {}
|
||||
|
||||
const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<footer
|
||||
className={`flex flex- justify-center p-2 ${className}`}
|
||||
style={{
|
||||
backgroundColor: "var(--backgroundPrimary)",
|
||||
color: "var(--textPrimary)",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<a
|
||||
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--link)" }}
|
||||
>
|
||||
Powered by ▲ Vercel
|
||||
</a>{" "}
|
||||
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "}
|
||||
<a
|
||||
href="https://meshtastic.org/docs/legal"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--link)" }}
|
||||
>
|
||||
Legal Information
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Footer;
|
||||
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 dark:bg-slate-800",
|
||||
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 mt-2 dark:bg-slate-800",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -21,79 +21,81 @@ export const Dashboard = () => {
|
||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<H3>Connected Devices</H3>
|
||||
<Subtle>Manage, connect and disconnect devices</Subtle>
|
||||
<>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<H3>Connected Devices</H3>
|
||||
<Subtle>Manage, connect and disconnect devices</Subtle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||
{devices.length ? (
|
||||
<ul className="grow divide-y divide-gray-200">
|
||||
{devices.map((device) => {
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
?.longName ?? "UNK"}
|
||||
</p>
|
||||
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
|
||||
{device.connection?.connType === "ble" && (
|
||||
<>
|
||||
<BluetoothIcon size={16} />
|
||||
BLE
|
||||
</>
|
||||
)}
|
||||
{device.connection?.connType === "serial" && (
|
||||
<>
|
||||
<UsbIcon size={16} />
|
||||
Serial
|
||||
</>
|
||||
)}
|
||||
{device.connection?.connType === "http" && (
|
||||
<>
|
||||
<NetworkIcon size={16} />
|
||||
Network
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-gray-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="m-auto flex flex-col gap-3 text-center">
|
||||
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
|
||||
<H3>No Devices</H3>
|
||||
<Subtle>Connect atleast one device to get started</Subtle>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
New Connection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
||||
{devices.length ? (
|
||||
<ul className="grow divide-y divide-gray-200">
|
||||
{devices.map((device) => {
|
||||
return (
|
||||
<li key={device.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate text-sm font-medium text-accent">
|
||||
{device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
?.longName ?? "UNK"}
|
||||
</p>
|
||||
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
|
||||
{device.connection?.connType === "ble" && (
|
||||
<>
|
||||
<BluetoothIcon size={16} />
|
||||
BLE
|
||||
</>
|
||||
)}
|
||||
{device.connection?.connType === "serial" && (
|
||||
<>
|
||||
<UsbIcon size={16} />
|
||||
Serial
|
||||
</>
|
||||
)}
|
||||
{device.connection?.connType === "http" && (
|
||||
<>
|
||||
<NetworkIcon size={16} />
|
||||
Network
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="flex gap-2 text-sm text-gray-500">
|
||||
<UsersIcon
|
||||
size={20}
|
||||
className="text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="m-auto flex flex-col gap-3 text-center">
|
||||
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
|
||||
<H3>No Devices</H3>
|
||||
<Subtle>Connect atleast one device to get started</Subtle>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
New Connection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -67,60 +67,62 @@ export const MessagesPage = (): JSX.Element => {
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
<PageLayout
|
||||
label={`Messages: ${
|
||||
chatType === "broadcast" && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: chatType === "direct" && nodes.get(activeChat)
|
||||
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
|
||||
: "Loading..."
|
||||
}`}
|
||||
actions={
|
||||
chatType === "direct"
|
||||
? [
|
||||
{
|
||||
icon: WaypointsIcon,
|
||||
async onClick() {
|
||||
const targetNode = nodes.get(activeChat)?.num;
|
||||
if (targetNode === undefined) return;
|
||||
toast({
|
||||
title: "Sending Traceroute, please wait...",
|
||||
});
|
||||
await connection?.traceRoute(targetNode).then(() =>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<PageLayout
|
||||
label={`Messages: ${
|
||||
chatType === "broadcast" && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: chatType === "direct" && nodes.get(activeChat)
|
||||
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
|
||||
: "Loading..."
|
||||
}`}
|
||||
actions={
|
||||
chatType === "direct"
|
||||
? [
|
||||
{
|
||||
icon: WaypointsIcon,
|
||||
async onClick() {
|
||||
const targetNode = nodes.get(activeChat)?.num;
|
||||
if (targetNode === undefined) return;
|
||||
toast({
|
||||
title: "Traceroute sent.",
|
||||
}),
|
||||
);
|
||||
title: "Sending Traceroute, please wait...",
|
||||
});
|
||||
await connection?.traceRoute(targetNode).then(() =>
|
||||
toast({
|
||||
title: "Traceroute sent.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
{allChannels.map(
|
||||
(channel) =>
|
||||
activeChat === channel.index && (
|
||||
<ChannelChat
|
||||
key={channel.index}
|
||||
to="broadcast"
|
||||
messages={messages.broadcast.get(channel.index)}
|
||||
channel={channel.index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{filteredNodes.map(
|
||||
(node) =>
|
||||
activeChat === node.num && (
|
||||
<ChannelChat
|
||||
key={node.num}
|
||||
to={activeChat}
|
||||
messages={messages.direct.get(node.num)}
|
||||
channel={Types.ChannelNumber.Primary}
|
||||
traceroutes={traceroutes.get(node.num)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</PageLayout>
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
{allChannels.map(
|
||||
(channel) =>
|
||||
activeChat === channel.index && (
|
||||
<ChannelChat
|
||||
key={channel.index}
|
||||
to="broadcast"
|
||||
messages={messages.broadcast.get(channel.index)}
|
||||
channel={channel.index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{filteredNodes.map(
|
||||
(node) =>
|
||||
activeChat === node.num && (
|
||||
<ChannelChat
|
||||
key={node.num}
|
||||
to={activeChat}
|
||||
messages={messages.direct.get(node.num)}
|
||||
channel={Types.ChannelNumber.Primary}
|
||||
traceroutes={traceroutes.get(node.num)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Footer from "@app/components/UI/Footer";
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import { Sidebar } from "@components/Sidebar.js";
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
@@ -27,73 +28,76 @@ export const NodesPage = (): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<div className="w-full overflow-y-auto">
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Name", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
{ title: "Remove", type: "normal", sortable: false },
|
||||
]}
|
||||
rows={filteredNodes.map((node) => [
|
||||
<Hashicon key="icon" size={24} value={node.num.toString()} />,
|
||||
<h1 key="header">
|
||||
{node.user?.longName ??
|
||||
(node.user?.macaddr
|
||||
? `Meshtastic ${base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
: `UNK: ${node.num}`)}
|
||||
</h1>,
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="overflow-y-auto h-full">
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Name", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
{ title: "Remove", type: "normal", sortable: false },
|
||||
]}
|
||||
rows={filteredNodes.map((node) => [
|
||||
<Hashicon key="icon" size={24} value={node.num.toString()} />,
|
||||
<h1 key="header">
|
||||
{node.user?.longName ??
|
||||
(node.user?.macaddr
|
||||
? `Meshtastic ${base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
: `UNK: ${node.num}`)}
|
||||
</h1>,
|
||||
|
||||
<Mono key="model">
|
||||
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
||||
</Mono>,
|
||||
<Mono key="addr">
|
||||
{base16
|
||||
.stringify(node.user?.macaddr ?? [])
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? "UNK"}
|
||||
</Mono>,
|
||||
<Fragment key="lastHeard">
|
||||
{node.lastHeard === 0 ? (
|
||||
<p>Never</p>
|
||||
) : (
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
)}
|
||||
</Fragment>,
|
||||
<Mono key="snr">
|
||||
{node.snr}db/
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||
{(node.snr + 10) * 5}raw
|
||||
</Mono>,
|
||||
<Mono key="hops">
|
||||
{node.lastHeard !== 0
|
||||
? node.viaMqtt === false && node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway.toString()} ${
|
||||
node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||
</Mono>,
|
||||
<Button
|
||||
key="remove"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setNodeNumToBeRemoved(node.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</Button>,
|
||||
])}
|
||||
/>
|
||||
<Mono key="model">
|
||||
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
||||
</Mono>,
|
||||
<Mono key="addr">
|
||||
{base16
|
||||
.stringify(node.user?.macaddr ?? [])
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? "UNK"}
|
||||
</Mono>,
|
||||
<Fragment key="lastHeard">
|
||||
{node.lastHeard === 0 ? (
|
||||
<p>Never</p>
|
||||
) : (
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
)}
|
||||
</Fragment>,
|
||||
<Mono key="snr">
|
||||
{node.snr}db/
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||
{(node.snr + 10) * 5}raw
|
||||
</Mono>,
|
||||
<Mono key="hops">
|
||||
{node.lastHeard !== 0
|
||||
? node.viaMqtt === false && node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway.toString()} ${
|
||||
node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||
</Mono>,
|
||||
<Button
|
||||
key="remove"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setNodeNumToBeRemoved(node.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</Button>,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user