mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-25 20:41:10 +02:00
3586 lines
129 KiB
TypeScript
3586 lines
129 KiB
TypeScript
import {
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
lazy,
|
|
Suspense,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { ChevronDown, Info } from 'lucide-react';
|
|
import { Input } from '../ui/input';
|
|
import { Label } from '../ui/label';
|
|
import { Button } from '../ui/button';
|
|
import { Separator } from '../ui/separator';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '../ui/dialog';
|
|
import { toast } from '../ui/sonner';
|
|
import { cn } from '@/lib/utils';
|
|
import { api } from '../../api';
|
|
import type { Channel, Contact, FanoutConfig, HealthStatus } from '../../types';
|
|
|
|
const BotCodeEditor = lazy(() =>
|
|
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
|
|
);
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
mqtt_private: 'Private MQTT',
|
|
mqtt_community: 'Community Sharing',
|
|
mqtt_ha: 'Home Assistant',
|
|
bot: 'Python Bot',
|
|
webhook: 'Webhook',
|
|
apprise: 'Apprise',
|
|
sqs: 'Amazon SQS',
|
|
map_upload: 'Map Upload',
|
|
};
|
|
|
|
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
|
const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net';
|
|
const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net';
|
|
const DEFAULT_COMMUNITY_BROKER_PORT = 443;
|
|
const DEFAULT_COMMUNITY_TRANSPORT = 'websockets';
|
|
const DEFAULT_COMMUNITY_AUTH_MODE = 'token';
|
|
const DEFAULT_MESHRANK_BROKER_HOST = 'meshrank.net';
|
|
const DEFAULT_MESHRANK_BROKER_PORT = 8883;
|
|
const DEFAULT_MESHRANK_TRANSPORT = 'tcp';
|
|
const DEFAULT_MESHRANK_AUTH_MODE = 'none';
|
|
const DEFAULT_MESHRANK_IATA = 'XYZ';
|
|
|
|
function createCommunityConfigDefaults(
|
|
overrides: Partial<Record<string, unknown>> = {}
|
|
): Record<string, unknown> {
|
|
return {
|
|
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
|
|
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
|
|
transport: DEFAULT_COMMUNITY_TRANSPORT,
|
|
use_tls: true,
|
|
tls_verify: true,
|
|
auth_mode: DEFAULT_COMMUNITY_AUTH_MODE,
|
|
username: '',
|
|
password: '',
|
|
iata: '',
|
|
email: '',
|
|
token_audience: '',
|
|
topic_template: DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
|
|
"""
|
|
Process messages and optionally return a reply.
|
|
|
|
Args:
|
|
kwargs keys currently provided:
|
|
sender_name: Display name of sender (may be None)
|
|
sender_key: 64-char hex public key (None for channel msgs)
|
|
message_text: The message content
|
|
is_dm: True for direct messages, False for channel
|
|
channel_key: 32-char hex key for channels, None for DMs
|
|
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
|
|
sender_timestamp: Sender's timestamp (unix seconds, may be None)
|
|
path: Hex-encoded routing path (may be None)
|
|
is_outgoing: True if this is our own outgoing message
|
|
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
|
|
|
|
Returns:
|
|
None for no reply, a string for a single reply,
|
|
or a list of strings to send multiple messages in order
|
|
"""
|
|
sender_name = kwargs.get("sender_name")
|
|
message_text = kwargs.get("message_text", "")
|
|
channel_name = kwargs.get("channel_name")
|
|
is_outgoing = kwargs.get("is_outgoing", False)
|
|
path_bytes_per_hop = kwargs.get("path_bytes_per_hop")
|
|
|
|
# Don't reply to our own outgoing messages
|
|
if is_outgoing:
|
|
return None
|
|
|
|
# Example: Only respond in #bot channel to "!pling" command
|
|
if channel_name == "#bot" and "!pling" in message_text.lower():
|
|
return "[BOT] Plong!"
|
|
return None`;
|
|
|
|
type DraftType =
|
|
| 'mqtt_private'
|
|
| 'mqtt_ha'
|
|
| 'mqtt_community'
|
|
| 'mqtt_community_meshrank'
|
|
| 'mqtt_community_letsmesh_us'
|
|
| 'mqtt_community_letsmesh_eu'
|
|
| 'webhook'
|
|
| 'apprise'
|
|
| 'sqs'
|
|
| 'bot'
|
|
| 'map_upload';
|
|
|
|
type CreateIntegrationDefinition = {
|
|
value: DraftType;
|
|
savedType: string;
|
|
label: string;
|
|
section: string;
|
|
description: string;
|
|
defaultName: string;
|
|
nameMode: 'counted' | 'fixed';
|
|
defaults: {
|
|
config: Record<string, unknown>;
|
|
scope: Record<string, unknown>;
|
|
};
|
|
};
|
|
|
|
const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|
{
|
|
value: 'mqtt_private',
|
|
savedType: 'mqtt_private',
|
|
label: 'Private MQTT',
|
|
section: 'Private Forwarding',
|
|
description:
|
|
'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.',
|
|
defaultName: 'Private MQTT',
|
|
nameMode: 'counted',
|
|
defaults: {
|
|
config: {
|
|
broker_host: '',
|
|
broker_port: 1883,
|
|
username: '',
|
|
password: '',
|
|
use_tls: false,
|
|
tls_insecure: false,
|
|
topic_prefix: 'meshcore',
|
|
},
|
|
scope: { messages: 'all', raw_packets: 'all' },
|
|
},
|
|
},
|
|
{
|
|
value: 'mqtt_ha',
|
|
savedType: 'mqtt_ha',
|
|
label: 'Home Assistant MQTT Discovery',
|
|
section: 'Private Forwarding',
|
|
description:
|
|
"Publishes MQTT Discovery payloads so mesh devices appear natively in Home Assistant. Requires HA's built-in MQTT integration connected to the same broker. Select specific contacts for GPS tracking and repeaters for telemetry sensors.",
|
|
defaultName: 'Home Assistant',
|
|
nameMode: 'fixed',
|
|
defaults: {
|
|
config: {
|
|
broker_host: '',
|
|
broker_port: 1883,
|
|
username: '',
|
|
password: '',
|
|
use_tls: false,
|
|
tls_insecure: false,
|
|
topic_prefix: 'meshcore',
|
|
tracked_contacts: [],
|
|
tracked_repeaters: [],
|
|
},
|
|
scope: { messages: 'all', raw_packets: 'none' },
|
|
},
|
|
},
|
|
{
|
|
value: 'mqtt_community',
|
|
savedType: 'mqtt_community',
|
|
label: 'Community MQTT/meshcoretomqtt',
|
|
section: 'Community Sharing',
|
|
description:
|
|
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
|
|
defaultName: 'Community MQTT',
|
|
nameMode: 'counted',
|
|
defaults: {
|
|
config: createCommunityConfigDefaults(),
|
|
scope: { messages: 'none', raw_packets: 'all' },
|
|
},
|
|
},
|
|
{
|
|
value: 'mqtt_community_meshrank',
|
|
savedType: 'mqtt_community',
|
|
label: 'MeshRank',
|
|
section: 'Community Sharing',
|
|
description:
|
|
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
|
defaultName: 'MeshRank',
|
|
nameMode: 'fixed',
|
|
defaults: {
|
|
config: createCommunityConfigDefaults({
|
|
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
|
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
|
|
transport: DEFAULT_MESHRANK_TRANSPORT,
|
|
auth_mode: DEFAULT_MESHRANK_AUTH_MODE,
|
|
iata: DEFAULT_MESHRANK_IATA,
|
|
email: '',
|
|
token_audience: '',
|
|
topic_template: '',
|
|
}),
|
|
scope: { messages: 'none', raw_packets: 'all' },
|
|
},
|
|
},
|
|
{
|
|
value: 'mqtt_community_letsmesh_us',
|
|
savedType: 'mqtt_community',
|
|
label: 'LetsMesh (US)',
|
|
section: 'Community Sharing',
|
|
description:
|
|
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
|
defaultName: 'LetsMesh (US)',
|
|
nameMode: 'fixed',
|
|
defaults: {
|
|
config: createCommunityConfigDefaults({
|
|
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
|
|
token_audience: DEFAULT_COMMUNITY_BROKER_HOST,
|
|
}),
|
|
scope: { messages: 'none', raw_packets: 'all' },
|
|
},
|
|
},
|
|
{
|
|
value: 'mqtt_community_letsmesh_eu',
|
|
savedType: 'mqtt_community',
|
|
label: 'LetsMesh (EU)',
|
|
section: 'Community Sharing',
|
|
description:
|
|
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
|
defaultName: 'LetsMesh (EU)',
|
|
nameMode: 'fixed',
|
|
defaults: {
|
|
config: createCommunityConfigDefaults({
|
|
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
|
|
token_audience: DEFAULT_COMMUNITY_BROKER_HOST_EU,
|
|
}),
|
|
scope: { messages: 'none', raw_packets: 'all' },
|
|
},
|
|
},
|
|
{
|
|
value: 'webhook',
|
|
savedType: 'webhook',
|
|
label: 'Webhook',
|
|
section: 'Automation',
|
|
description:
|
|
'Generic webhook for decrypted channel/DM messages with customizable verb, method, and optional HMAC signature.',
|
|
defaultName: 'Webhook',
|
|
nameMode: 'counted',
|
|
defaults: {
|
|
config: {
|
|
url: '',
|
|
method: 'POST',
|
|
headers: {},
|
|
hmac_secret: '',
|
|
hmac_header: '',
|
|
},
|
|
scope: { messages: 'all', raw_packets: 'none' },
|
|
},
|
|
},
|
|
{
|
|
value: 'apprise',
|
|
savedType: 'apprise',
|
|
label: 'Apprise',
|
|
section: 'Automation',
|
|
description:
|
|
'A wide-ranging generic fanout, capable of forwarding decrypted channel/DM messages to Discord, Telegram, email, SMS, and many others.',
|
|
defaultName: 'Apprise',
|
|
nameMode: 'counted',
|
|
defaults: {
|
|
config: {
|
|
urls: '',
|
|
preserve_identity: true,
|
|
include_outgoing: false,
|
|
markdown_format: true,
|
|
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
|
body_format_channel:
|
|
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
|
},
|
|
scope: { messages: 'all', raw_packets: 'none' },
|
|
},
|
|
},
|
|
{
|
|
value: 'sqs',
|
|
savedType: 'sqs',
|
|
label: 'Amazon SQS',
|
|
section: 'Private Forwarding',
|
|
description: 'Send full or scope-customized raw or decrypted packets to an SQS',
|
|
defaultName: 'Amazon SQS',
|
|
nameMode: 'counted',
|
|
defaults: {
|
|
config: {
|
|
queue_url: '',
|
|
region_name: '',
|
|
endpoint_url: '',
|
|
access_key_id: '',
|
|
secret_access_key: '',
|
|
session_token: '',
|
|
},
|
|
scope: { messages: 'all', raw_packets: 'none' },
|
|
},
|
|
},
|
|
{
|
|
value: 'bot',
|
|
savedType: 'bot',
|
|
label: 'Python Bot',
|
|
section: 'Automation',
|
|
description:
|
|
'A simple, Python-based interface for basic bots that can respond to DM and channel messages.',
|
|
defaultName: 'Bot',
|
|
nameMode: 'counted',
|
|
defaults: {
|
|
config: {
|
|
code: DEFAULT_BOT_CODE,
|
|
},
|
|
scope: { messages: 'all', raw_packets: 'none' },
|
|
},
|
|
},
|
|
{
|
|
value: 'map_upload',
|
|
savedType: 'map_upload',
|
|
label: 'Map Upload',
|
|
section: 'Community Sharing',
|
|
description:
|
|
'Upload repeaters and room servers to map.meshcore.io or a compatible map API endpoint.',
|
|
defaultName: 'Map Upload',
|
|
nameMode: 'counted',
|
|
defaults: {
|
|
config: {
|
|
api_url: '',
|
|
dry_run: true,
|
|
},
|
|
scope: { messages: 'none', raw_packets: 'all' },
|
|
},
|
|
},
|
|
];
|
|
|
|
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
|
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
|
) as Record<DraftType, CreateIntegrationDefinition>;
|
|
|
|
function getNumberInputValue(value: unknown, fallback: number): string | number {
|
|
if (value === '') return '';
|
|
if (typeof value === 'string') return value;
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
return fallback;
|
|
}
|
|
|
|
function getOptionalNumberInputValue(value: unknown): string | number {
|
|
if (value === '') return '';
|
|
if (typeof value === 'string') return value;
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
return '';
|
|
}
|
|
|
|
function parseIntegerInputValue(value: string): number | string {
|
|
if (value === '') return '';
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isNaN(parsed) ? value : parsed;
|
|
}
|
|
|
|
function parseFloatInputValue(value: string): number | string {
|
|
if (value === '') return '';
|
|
const parsed = Number.parseFloat(value);
|
|
return Number.isNaN(parsed) ? value : parsed;
|
|
}
|
|
|
|
function normalizeIntegrationConfigForSave(
|
|
configType: string,
|
|
config: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
const normalized = { ...config };
|
|
|
|
if (configType === 'mqtt_private') {
|
|
const port = normalized.broker_port;
|
|
if (port === '' || port === undefined || port === null) {
|
|
normalized.broker_port = 1883;
|
|
} else if (typeof port === 'string') {
|
|
const parsed = Number.parseInt(port, 10);
|
|
normalized.broker_port = Number.isNaN(parsed) ? 1883 : parsed;
|
|
}
|
|
|
|
const topicPrefix = String(normalized.topic_prefix ?? '').trim();
|
|
normalized.topic_prefix = topicPrefix || 'meshcore';
|
|
}
|
|
|
|
if (configType === 'mqtt_community') {
|
|
const brokerHost = String(normalized.broker_host ?? '').trim();
|
|
normalized.broker_host = brokerHost || DEFAULT_COMMUNITY_BROKER_HOST;
|
|
|
|
const port = normalized.broker_port;
|
|
if (port === '' || port === undefined || port === null) {
|
|
normalized.broker_port = DEFAULT_COMMUNITY_BROKER_PORT;
|
|
} else if (typeof port === 'string') {
|
|
const parsed = Number.parseInt(port, 10);
|
|
normalized.broker_port = Number.isNaN(parsed) ? DEFAULT_COMMUNITY_BROKER_PORT : parsed;
|
|
}
|
|
|
|
const topicTemplate = String(normalized.topic_template ?? '').trim();
|
|
normalized.topic_template = topicTemplate || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE;
|
|
}
|
|
|
|
if (configType === 'map_upload') {
|
|
const radius = normalized.geofence_radius_km;
|
|
if (radius === '' || radius === undefined || radius === null) {
|
|
normalized.geofence_radius_km = 0;
|
|
} else if (typeof radius === 'string') {
|
|
const parsed = Number.parseFloat(radius);
|
|
normalized.geofence_radius_km = Number.isNaN(parsed) ? 0 : parsed;
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function isDraftType(value: string): value is DraftType {
|
|
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
|
}
|
|
|
|
function getCreateIntegrationDefinition(draftType: DraftType) {
|
|
return CREATE_INTEGRATION_DEFINITIONS_BY_VALUE[draftType];
|
|
}
|
|
|
|
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
|
|
const definition = getCreateIntegrationDefinition(draftType);
|
|
if (name) return name;
|
|
if (definition.nameMode === 'fixed') return definition.defaultName;
|
|
return getDefaultIntegrationName(definition.savedType, configs);
|
|
}
|
|
|
|
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
|
|
if (draftType === 'mqtt_community_meshrank') {
|
|
const topicTemplate = String(config.topic_template || '').trim();
|
|
if (!topicTemplate) {
|
|
throw new Error('MeshRank packet topic is required');
|
|
}
|
|
|
|
return normalizeIntegrationConfigForSave('mqtt_community', {
|
|
...config,
|
|
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
|
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
|
|
transport: DEFAULT_MESHRANK_TRANSPORT,
|
|
auth_mode: DEFAULT_MESHRANK_AUTH_MODE,
|
|
use_tls: true,
|
|
tls_verify: true,
|
|
iata: DEFAULT_MESHRANK_IATA,
|
|
email: '',
|
|
token_audience: '',
|
|
topic_template: topicTemplate,
|
|
username: '',
|
|
password: '',
|
|
});
|
|
}
|
|
|
|
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
|
|
const brokerHost =
|
|
draftType === 'mqtt_community_letsmesh_eu'
|
|
? DEFAULT_COMMUNITY_BROKER_HOST_EU
|
|
: DEFAULT_COMMUNITY_BROKER_HOST;
|
|
return normalizeIntegrationConfigForSave('mqtt_community', {
|
|
...config,
|
|
broker_host: brokerHost,
|
|
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
|
|
transport: DEFAULT_COMMUNITY_TRANSPORT,
|
|
auth_mode: DEFAULT_COMMUNITY_AUTH_MODE,
|
|
use_tls: true,
|
|
tls_verify: true,
|
|
token_audience: brokerHost,
|
|
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
|
|
username: '',
|
|
password: '',
|
|
});
|
|
}
|
|
|
|
return normalizeIntegrationConfigForSave(
|
|
getCreateIntegrationDefinition(draftType).savedType,
|
|
config
|
|
);
|
|
}
|
|
|
|
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
|
if (getCreateIntegrationDefinition(draftType).savedType === 'mqtt_community') {
|
|
return { messages: 'none', raw_packets: 'all' };
|
|
}
|
|
return scope;
|
|
}
|
|
|
|
function cloneDraftDefaults(draftType: DraftType) {
|
|
const recipe = getCreateIntegrationDefinition(draftType);
|
|
return {
|
|
config: structuredClone(recipe.defaults.config),
|
|
scope: structuredClone(recipe.defaults.scope),
|
|
};
|
|
}
|
|
|
|
function CreateIntegrationDialog({
|
|
open,
|
|
options,
|
|
selectedType,
|
|
onOpenChange,
|
|
onSelect,
|
|
onCreate,
|
|
}: {
|
|
open: boolean;
|
|
options: readonly CreateIntegrationDefinition[];
|
|
selectedType: DraftType | null;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSelect: (type: DraftType) => void;
|
|
onCreate: () => void;
|
|
}) {
|
|
const selectedOption =
|
|
options.find((option) => option.value === selectedType) ?? options[0] ?? null;
|
|
const listRef = useRef<HTMLDivElement | null>(null);
|
|
const [showScrollHint, setShowScrollHint] = useState(false);
|
|
|
|
const updateScrollHint = useCallback(() => {
|
|
const container = listRef.current;
|
|
if (!container) {
|
|
setShowScrollHint(false);
|
|
return;
|
|
}
|
|
setShowScrollHint(container.scrollTop + container.clientHeight < container.scrollHeight - 8);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const frame = window.requestAnimationFrame(updateScrollHint);
|
|
window.addEventListener('resize', updateScrollHint);
|
|
return () => {
|
|
window.cancelAnimationFrame(frame);
|
|
window.removeEventListener('resize', updateScrollHint);
|
|
};
|
|
}, [open, options, updateScrollHint]);
|
|
|
|
const sectionedOptions = [...new Set(options.map((o) => o.section))]
|
|
.map((section) => ({
|
|
section,
|
|
options: options.filter((option) => option.section === section),
|
|
}))
|
|
.filter((group) => group.options.length > 0);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
aria-describedby={undefined}
|
|
hideCloseButton
|
|
className="flex max-h-[calc(100dvh-2rem)] w-[96vw] max-w-[960px] flex-col overflow-hidden p-0 sm:rounded-xl"
|
|
>
|
|
<DialogHeader className="border-b border-border px-5 py-4">
|
|
<DialogTitle>Create Integration</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[240px_minmax(0,1fr)]">
|
|
<div className="relative border-b border-border bg-muted/20 md:border-b-0 md:border-r">
|
|
<div
|
|
ref={listRef}
|
|
onScroll={updateScrollHint}
|
|
className="max-h-56 overflow-y-auto p-2 md:max-h-[420px]"
|
|
>
|
|
<div className="space-y-4">
|
|
{sectionedOptions.map((group) => (
|
|
<div key={group.section} className="space-y-1.5">
|
|
<div className="px-2 text-[0.6875rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{group.section}
|
|
</div>
|
|
{group.options.map((option) => {
|
|
const selected = option.value === selectedOption?.value;
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
className={cn(
|
|
'w-full rounded-md border px-3 py-2 text-left transition-colors',
|
|
selected
|
|
? 'border-primary bg-accent text-foreground'
|
|
: 'border-transparent bg-transparent hover:bg-accent/70'
|
|
)}
|
|
aria-pressed={selected}
|
|
onClick={() => onSelect(option.value)}
|
|
>
|
|
<div className="text-sm font-medium">{option.label}</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{showScrollHint && (
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-background via-background/85 to-transparent px-4 pb-2 pt-8">
|
|
<div className="rounded-full border border-border/80 bg-background/95 px-2 py-1 text-muted-foreground shadow-sm">
|
|
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5 md:min-h-[280px] md:max-h-[420px]">
|
|
{selectedOption ? (
|
|
<>
|
|
<div className="space-y-1.5">
|
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{selectedOption.section}
|
|
</div>
|
|
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
|
</div>
|
|
|
|
<p className="text-sm leading-6 text-muted-foreground">
|
|
{selectedOption.description}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
No integration types are currently available.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 border-t border-border px-5 py-4 sm:justify-end">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Close
|
|
</Button>
|
|
<Button onClick={onCreate} disabled={!selectedOption}>
|
|
Create
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function getDetailTypeLabel(detailType: string) {
|
|
if (isDraftType(detailType)) return getCreateIntegrationDefinition(detailType).label;
|
|
return TYPE_LABELS[detailType] || detailType;
|
|
}
|
|
|
|
function fanoutDraftHasUnsavedChanges(
|
|
original: FanoutConfig | null,
|
|
current: {
|
|
name: string;
|
|
config: Record<string, unknown>;
|
|
scope: Record<string, unknown>;
|
|
}
|
|
) {
|
|
if (!original) return false;
|
|
return (
|
|
current.name !== original.name ||
|
|
JSON.stringify(current.config) !== JSON.stringify(original.config) ||
|
|
JSON.stringify(current.scope) !== JSON.stringify(original.scope)
|
|
);
|
|
}
|
|
|
|
function formatBrokerSummary(
|
|
config: Record<string, unknown>,
|
|
defaults: { host: string; port: number }
|
|
) {
|
|
const host = (config.broker_host as string) || defaults.host;
|
|
const port = typeof config.broker_port === 'number' ? config.broker_port : defaults.port;
|
|
return `${host}:${port}`;
|
|
}
|
|
|
|
function formatPrivateTopicSummary(config: Record<string, unknown>) {
|
|
const prefix = (config.topic_prefix as string) || 'meshcore';
|
|
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
|
|
}
|
|
|
|
function censorAppriseUrl(url: string): string {
|
|
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
|
|
if (protoMatch) return `${protoMatch[0]}********`;
|
|
return '********';
|
|
}
|
|
|
|
function formatAppriseTargets(urls: string | undefined) {
|
|
const targets = (urls || '')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
if (targets.length === 0) return 'No targets configured';
|
|
|
|
return targets.map(censorAppriseUrl).join(', ');
|
|
}
|
|
|
|
function formatSqsQueueSummary(config: Record<string, unknown>) {
|
|
const queueUrl = ((config.queue_url as string) || '').trim();
|
|
if (!queueUrl) return 'No queue configured';
|
|
return queueUrl;
|
|
}
|
|
|
|
function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
|
|
const label = TYPE_LABELS[type] || type;
|
|
const nextIndex = configs.filter((cfg) => cfg.type === type).length + 1;
|
|
return `${label} #${nextIndex}`;
|
|
}
|
|
|
|
function getStatusLabel(status: string | undefined, type?: string) {
|
|
if (status === 'connected')
|
|
return type === 'bot' || type === 'webhook' || type === 'apprise' || type === 'map_upload'
|
|
? 'Active'
|
|
: 'Connected';
|
|
if (status === 'error') return 'Error';
|
|
if (status === 'disconnected') return 'Disconnected';
|
|
return 'Inactive';
|
|
}
|
|
|
|
function getStatusColor(status: string | undefined, enabled?: boolean) {
|
|
if (enabled === false) return 'bg-muted-foreground';
|
|
if (status === 'connected')
|
|
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
|
|
if (status === 'error' || status === 'disconnected')
|
|
return 'bg-destructive shadow-[0_0_6px_hsl(var(--destructive)/0.5)]';
|
|
return 'bg-muted-foreground';
|
|
}
|
|
|
|
function MqttPrivateConfigEditor({
|
|
config,
|
|
scope,
|
|
onChange,
|
|
onScopeChange,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
scope: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
onScopeChange: (scope: Record<string, unknown>) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
|
|
</p>
|
|
|
|
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
|
Outgoing messages (DMs and group messages) will be reported to private MQTT brokers in
|
|
decrypted/plaintext form.
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-mqtt-host">Broker Host</Label>
|
|
<Input
|
|
id="fanout-mqtt-host"
|
|
type="text"
|
|
placeholder="e.g. 192.168.1.100"
|
|
value={(config.broker_host as string) || ''}
|
|
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-mqtt-port">Broker Port</Label>
|
|
<Input
|
|
id="fanout-mqtt-port"
|
|
type="number"
|
|
min="1"
|
|
max="65535"
|
|
value={getNumberInputValue(config.broker_port, 1883)}
|
|
onChange={(e) =>
|
|
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-mqtt-user">Username</Label>
|
|
<Input
|
|
id="fanout-mqtt-user"
|
|
type="text"
|
|
placeholder="Optional"
|
|
value={(config.username as string) || ''}
|
|
onChange={(e) => onChange({ ...config, username: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-mqtt-pass">Password</Label>
|
|
<Input
|
|
id="fanout-mqtt-pass"
|
|
type="password"
|
|
placeholder="Optional"
|
|
value={(config.password as string) || ''}
|
|
onChange={(e) => onChange({ ...config, password: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!config.use_tls}
|
|
onChange={(e) => onChange({ ...config, use_tls: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<span className="text-sm">Use TLS</span>
|
|
</label>
|
|
|
|
{!!config.use_tls && (
|
|
<label className="flex items-center gap-3 cursor-pointer ml-7">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!config.tls_insecure}
|
|
onChange={(e) => onChange({ ...config, tls_insecure: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<span className="text-sm">Skip certificate verification</span>
|
|
</label>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-mqtt-prefix">Topic Prefix</Label>
|
|
<Input
|
|
id="fanout-mqtt-prefix"
|
|
type="text"
|
|
placeholder="meshcore"
|
|
value={(config.topic_prefix as string | undefined) ?? ''}
|
|
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<ScopeSelector scope={scope} onChange={onScopeChange} showRawPackets />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MqttHaConfigEditor({
|
|
config,
|
|
scope,
|
|
onChange,
|
|
onScopeChange,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
scope: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
onScopeChange: (scope: Record<string, unknown>) => void;
|
|
}) {
|
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
const [trackedRepeaters, setTrackedRepeaters] = useState<string[]>([]);
|
|
const [contactSearch, setContactSearch] = useState('');
|
|
const [radioConfig, setRadioConfig] = useState<{ public_key: string; name: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
const all: Contact[] = [];
|
|
const pageSize = 1000;
|
|
let offset = 0;
|
|
while (true) {
|
|
const page = await api.getContacts(pageSize, offset);
|
|
all.push(...page);
|
|
if (page.length < pageSize) break;
|
|
offset += pageSize;
|
|
}
|
|
setContacts(all);
|
|
})().catch(console.error);
|
|
|
|
api
|
|
.getRadioConfig()
|
|
.then((radio) => setRadioConfig({ public_key: radio.public_key, name: radio.name }))
|
|
.catch(console.error);
|
|
|
|
api
|
|
.getSettings()
|
|
.then((s) => setTrackedRepeaters(s.tracked_telemetry_repeaters ?? []))
|
|
.catch(console.error);
|
|
}, []);
|
|
|
|
const selectedContacts = (config.tracked_contacts as string[]) || [];
|
|
const selectedRepeaters = (config.tracked_repeaters as string[]) || [];
|
|
|
|
const contactOptions = useMemo(
|
|
() => contacts.filter((c) => c.type === 0 || c.type === 1 || c.type === 3),
|
|
[contacts]
|
|
);
|
|
|
|
const repeaterOptions = useMemo(
|
|
() => contacts.filter((c) => c.type === 2 && trackedRepeaters.includes(c.public_key)),
|
|
[contacts, trackedRepeaters]
|
|
);
|
|
|
|
const contactSearchLower = contactSearch.toLowerCase().trim();
|
|
const filteredContacts = useMemo(() => {
|
|
const matches = contactOptions.filter((c) => {
|
|
if (!contactSearchLower) return true;
|
|
const name = (c.name || '').toLowerCase();
|
|
const key = c.public_key.toLowerCase();
|
|
return name.includes(contactSearchLower) || key.startsWith(contactSearchLower);
|
|
});
|
|
// Selected contacts sort to top
|
|
return matches.sort((a, b) => {
|
|
const aSelected = selectedContacts.includes(a.public_key) ? 0 : 1;
|
|
const bSelected = selectedContacts.includes(b.public_key) ? 0 : 1;
|
|
if (aSelected !== bSelected) return aSelected - bSelected;
|
|
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
|
});
|
|
}, [contactOptions, contactSearchLower, selectedContacts]);
|
|
|
|
const selectedContactDetails = contactOptions.filter((c) =>
|
|
selectedContacts.includes(c.public_key)
|
|
);
|
|
const selectedRepeaterDetails = repeaterOptions.filter((c) =>
|
|
selectedRepeaters.includes(c.public_key)
|
|
);
|
|
const prefix = ((config.topic_prefix as string) || 'meshcore').trim() || 'meshcore';
|
|
|
|
const nodeIdForKey = useCallback((publicKey: string) => publicKey.slice(0, 12).toLowerCase(), []);
|
|
|
|
const topicSummary = useMemo(() => {
|
|
const items: Array<{
|
|
kind: 'radio' | 'event' | 'repeater' | 'contact';
|
|
label: string;
|
|
publicKey: string;
|
|
nodeId: string;
|
|
topics: string[];
|
|
}> = [];
|
|
|
|
if (radioConfig?.public_key) {
|
|
const nodeId = nodeIdForKey(radioConfig.public_key);
|
|
items.push({
|
|
kind: 'radio',
|
|
label: radioConfig.name || radioConfig.public_key.slice(0, 12),
|
|
publicKey: radioConfig.public_key,
|
|
nodeId,
|
|
topics: [`${prefix}/${nodeId}/health`],
|
|
});
|
|
items.push({
|
|
kind: 'event',
|
|
label: radioConfig.name || radioConfig.public_key.slice(0, 12),
|
|
publicKey: radioConfig.public_key,
|
|
nodeId,
|
|
topics: [`${prefix}/${nodeId}/events/message`],
|
|
});
|
|
}
|
|
|
|
for (const repeater of selectedRepeaterDetails) {
|
|
const nodeId = nodeIdForKey(repeater.public_key);
|
|
items.push({
|
|
kind: 'repeater',
|
|
label: repeater.name || repeater.public_key.slice(0, 12),
|
|
publicKey: repeater.public_key,
|
|
nodeId,
|
|
topics: [`${prefix}/${nodeId}/telemetry`],
|
|
});
|
|
}
|
|
|
|
for (const contact of selectedContactDetails) {
|
|
const nodeId = nodeIdForKey(contact.public_key);
|
|
items.push({
|
|
kind: 'contact',
|
|
label: contact.name || contact.public_key.slice(0, 12),
|
|
publicKey: contact.public_key,
|
|
nodeId,
|
|
topics: [`${prefix}/${nodeId}/gps`],
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}, [nodeIdForKey, prefix, radioConfig, selectedContactDetails, selectedRepeaterDetails]);
|
|
|
|
const kindLabel: Record<(typeof topicSummary)[number]['kind'], string> = {
|
|
radio: 'Local radio state',
|
|
event: 'Message events',
|
|
repeater: 'Repeater telemetry',
|
|
contact: 'Contact GPS',
|
|
};
|
|
const localRadioNodeId = radioConfig?.public_key
|
|
? nodeIdForKey(radioConfig.public_key)
|
|
: '<radio_node_id>';
|
|
const exampleRepeaterNodeId =
|
|
selectedRepeaterDetails.length > 0
|
|
? nodeIdForKey(selectedRepeaterDetails[0].public_key)
|
|
: '<repeater_node_id>';
|
|
const exampleContactNodeId =
|
|
selectedContactDetails.length > 0
|
|
? nodeIdForKey(selectedContactDetails[0].public_key)
|
|
: '<contact_node_id>';
|
|
|
|
const toggleTrackedContact = (key: string) => {
|
|
const current = [...selectedContacts];
|
|
const idx = current.indexOf(key);
|
|
if (idx >= 0) current.splice(idx, 1);
|
|
else current.push(key);
|
|
onChange({ ...config, tracked_contacts: current });
|
|
};
|
|
|
|
const toggleTrackedRepeater = (key: string) => {
|
|
const current = [...selectedRepeaters];
|
|
const idx = current.indexOf(key);
|
|
if (idx >= 0) current.splice(idx, 1);
|
|
else current.push(key);
|
|
onChange({ ...config, tracked_repeaters: current });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
|
<div className="space-y-1">
|
|
<h3 className="text-base font-semibold tracking-tight">Home Assistant MQTT Discovery</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Publish discovery configs and MeshCore state to your MQTT broker so Home Assistant
|
|
creates native devices, sensors, GPS trackers, and message events automatically.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-2 md:grid-cols-3">
|
|
<div className="rounded-md border border-border/70 bg-background/80 p-3">
|
|
<div className="text-sm font-medium text-foreground">1. Same broker</div>
|
|
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
|
|
Home Assistant's built-in MQTT integration must point at the same broker
|
|
configured below.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-md border border-border/70 bg-background/80 p-3">
|
|
<div className="text-sm font-medium text-foreground">2. Pick what to expose</div>
|
|
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
|
|
Choose repeaters for telemetry sensors and contacts for GPS tracker entities.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-md border border-border/70 bg-background/80 p-3">
|
|
<div className="text-sm font-medium text-foreground">3. Automate in HA</div>
|
|
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
|
|
Radio health and message events publish continuously; repeater and contact data update
|
|
when new data is heard or collected.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Uses{' '}
|
|
<span
|
|
role="link"
|
|
tabIndex={0}
|
|
className="underline cursor-pointer hover:text-primary transition-colors"
|
|
onClick={() =>
|
|
window.open(
|
|
'https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery',
|
|
'_blank'
|
|
)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter')
|
|
window.open(
|
|
'https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery',
|
|
'_blank'
|
|
);
|
|
}}
|
|
>
|
|
MQTT Discovery
|
|
</span>{' '}
|
|
and the topic conventions documented in{' '}
|
|
<span
|
|
role="link"
|
|
tabIndex={0}
|
|
className="underline cursor-pointer hover:text-primary transition-colors"
|
|
onClick={() =>
|
|
window.open(
|
|
'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md',
|
|
'_blank'
|
|
)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter')
|
|
window.open(
|
|
'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md',
|
|
'_blank'
|
|
);
|
|
}}
|
|
>
|
|
README_HA.md
|
|
</span>
|
|
.
|
|
</p>
|
|
</div>
|
|
|
|
<details className="group">
|
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
|
What gets created in Home Assistant
|
|
</summary>
|
|
<div className="mt-2 space-y-2 text-sm text-muted-foreground rounded-md border border-border bg-muted/20 p-3">
|
|
<div>
|
|
<span className="font-medium text-foreground">Local radio device</span> (always)
|
|
<span className="ml-1">— updates every 60s</span>
|
|
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
|
|
<li>
|
|
<code className="text-[0.6875rem]">
|
|
{`binary_sensor.meshcore_${localRadioNodeId}_connected`}
|
|
</code>{' '}
|
|
— radio online/offline
|
|
</li>
|
|
<li>
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${localRadioNodeId}_noise_floor`}
|
|
</code>{' '}
|
|
— radio noise floor (dBm)
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="font-medium text-foreground">Per tracked repeater</span> —
|
|
updates on telemetry collect cycle (~8h) or manual dashboard fetch. Entity IDs shown use
|
|
one repeater for example; these sensors are created for each selected repeater.
|
|
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
|
|
<li>
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${exampleRepeaterNodeId}_battery_voltage`}
|
|
</code>{' '}
|
|
(V)
|
|
</li>
|
|
<li>
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${exampleRepeaterNodeId}_noise_floor`}
|
|
</code>
|
|
,{' '}
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${exampleRepeaterNodeId}_last_rssi`}
|
|
</code>
|
|
,{' '}
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${exampleRepeaterNodeId}_last_snr`}
|
|
</code>{' '}
|
|
(dBm/dB)
|
|
</li>
|
|
<li>
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${exampleRepeaterNodeId}_packets_received`}
|
|
</code>
|
|
,{' '}
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${exampleRepeaterNodeId}_packets_sent`}
|
|
</code>
|
|
</li>
|
|
<li>
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${exampleRepeaterNodeId}_uptime`}
|
|
</code>{' '}
|
|
(seconds)
|
|
</li>
|
|
<li>
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${exampleRepeaterNodeId}_lpp_temperature_ch1`}
|
|
</code>
|
|
,{' '}
|
|
<code className="text-[0.6875rem]">
|
|
{`sensor.meshcore_${exampleRepeaterNodeId}_lpp_humidity_ch1`}
|
|
</code>
|
|
, etc. — CayenneLPP sensors (auto-detected from repeater)
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="font-medium text-foreground">Per tracked contact</span> — updates
|
|
passively when advertisements with GPS are heard. Shown for one contact; a tracker is
|
|
created for each selected contact.
|
|
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
|
|
<li>
|
|
<code className="text-[0.6875rem]">
|
|
{`device_tracker.meshcore_${exampleContactNodeId}`}
|
|
</code>{' '}
|
|
— latitude/longitude
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="font-medium text-foreground">Message events</span> — fires for
|
|
each message matching the scope below
|
|
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
|
|
<li>
|
|
<code className="text-[0.6875rem]">
|
|
{`event.meshcore_${localRadioNodeId}_messages`}
|
|
</code>{' '}
|
|
— trigger automations on sender, channel, or message content
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<p className="text-[0.6875rem] mt-1.5">
|
|
Entity IDs use the first 12 characters of the node's public key. Entities are
|
|
removed from HA when this integration is disabled or deleted. State topics are published
|
|
under{' '}
|
|
<code className="text-[0.6875rem]">{prefix}/<node_id>/health|telemetry|gps</code>.
|
|
</p>
|
|
</div>
|
|
</details>
|
|
|
|
<details className="group">
|
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
|
Published topic summary
|
|
</summary>
|
|
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/20 p-3">
|
|
<p className="text-xs text-muted-foreground">
|
|
Home Assistant device and entity IDs are keyed off the first 12 characters of each
|
|
node's public key, not the display name. Those same 12 characters are used in the
|
|
MQTT state topics below.
|
|
</p>
|
|
{topicSummary.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground italic">
|
|
No topic previews available yet. Connect to a radio to resolve the local radio key,
|
|
and select contacts or repeaters above to preview their published topics.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{topicSummary.map((item) => (
|
|
<div
|
|
key={`${item.kind}-${item.publicKey}`}
|
|
className="rounded border border-border/70 bg-background/70 p-2"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
|
<span className="font-medium text-foreground">{kindLabel[item.kind]}</span>
|
|
<span className="text-foreground">{item.label}</span>
|
|
<span className="font-mono text-[0.6875rem] text-muted-foreground">
|
|
node id {item.nodeId}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-[0.6875rem] text-muted-foreground font-mono break-all">
|
|
key {item.publicKey}
|
|
</div>
|
|
{item.topics.map((topic) => (
|
|
<div
|
|
key={topic}
|
|
className="mt-1 rounded bg-muted px-2 py-1 text-[0.6875rem] font-mono text-foreground break-all"
|
|
>
|
|
{topic}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-[0.6875rem] text-muted-foreground">
|
|
Discovery config topics are also published under{' '}
|
|
<code className="text-[0.6875rem]">homeassistant/.../config</code>, but the topics above
|
|
are the primary runtime state and event topics.
|
|
</p>
|
|
</div>
|
|
</details>
|
|
|
|
<Separator />
|
|
|
|
<h3 className="text-base font-semibold tracking-tight">MQTT Broker</h3>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-ha-host">Broker Host</Label>
|
|
<Input
|
|
id="fanout-ha-host"
|
|
type="text"
|
|
placeholder="e.g. 192.168.1.100"
|
|
value={(config.broker_host as string) || ''}
|
|
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-ha-port">Broker Port</Label>
|
|
<Input
|
|
id="fanout-ha-port"
|
|
type="number"
|
|
min="1"
|
|
max="65535"
|
|
value={getNumberInputValue(config.broker_port, 1883)}
|
|
onChange={(e) =>
|
|
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-ha-user">Username</Label>
|
|
<Input
|
|
id="fanout-ha-user"
|
|
type="text"
|
|
placeholder="Optional"
|
|
value={(config.username as string) || ''}
|
|
onChange={(e) => onChange({ ...config, username: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-ha-pass">Password</Label>
|
|
<Input
|
|
id="fanout-ha-pass"
|
|
type="password"
|
|
placeholder="Optional"
|
|
value={(config.password as string) || ''}
|
|
onChange={(e) => onChange({ ...config, password: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!config.use_tls}
|
|
onChange={(e) => onChange({ ...config, use_tls: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<span className="text-sm">Use TLS</span>
|
|
</label>
|
|
|
|
{!!config.use_tls && (
|
|
<label className="flex items-center gap-3 cursor-pointer ml-7">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!config.tls_insecure}
|
|
onChange={(e) => onChange({ ...config, tls_insecure: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<span className="text-sm">Skip certificate verification</span>
|
|
</label>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-ha-prefix">Topic Prefix</Label>
|
|
<Input
|
|
id="fanout-ha-prefix"
|
|
type="text"
|
|
placeholder="meshcore"
|
|
value={(config.topic_prefix as string | undefined) ?? ''}
|
|
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
|
|
/>
|
|
<p className="text-[0.6875rem] text-muted-foreground">
|
|
State updates publish under <code className="text-[0.6875rem]">{prefix}/</code>. Discovery
|
|
configs always use the <code className="text-[0.6875rem]">homeassistant/</code> prefix.
|
|
</p>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-base font-semibold tracking-tight">GPS Tracked Contacts</h3>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Each selected contact becomes a <code className="text-[0.6875rem]">device_tracker</code>{' '}
|
|
in HA, updated whenever an advertisement with GPS coordinates is heard. Useful for
|
|
tracking mobile nodes on an HA map dashboard.
|
|
</p>
|
|
|
|
{selectedContactDetails.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{selectedContactDetails.map((c) => (
|
|
<span
|
|
key={c.public_key}
|
|
className="inline-flex items-center gap-1 text-[0.6875rem] px-2 py-0.5 rounded-full bg-primary/10 text-primary"
|
|
>
|
|
{c.name || c.public_key.slice(0, 12)}
|
|
<button
|
|
type="button"
|
|
className="ml-0.5 hover:text-destructive transition-colors"
|
|
onClick={() => toggleTrackedContact(c.public_key)}
|
|
aria-label={`Remove ${c.name || c.public_key.slice(0, 12)}`}
|
|
>
|
|
×
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{contactOptions.length === 0 ? (
|
|
<p className="text-[0.8125rem] text-muted-foreground italic">No contacts available.</p>
|
|
) : (
|
|
<>
|
|
<Input
|
|
type="text"
|
|
placeholder={`Search ${contactOptions.length} contacts...`}
|
|
value={contactSearch}
|
|
onChange={(e) => setContactSearch(e.target.value)}
|
|
className="h-8 text-sm"
|
|
/>
|
|
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
|
|
{filteredContacts.length === 0 ? (
|
|
<p className="text-[0.8125rem] text-muted-foreground italic py-1">
|
|
No contacts match “{contactSearch}”
|
|
</p>
|
|
) : (
|
|
filteredContacts.map((c) => (
|
|
<label
|
|
key={c.public_key}
|
|
className="flex items-center gap-2 cursor-pointer text-sm"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedContacts.includes(c.public_key)}
|
|
onChange={() => toggleTrackedContact(c.public_key)}
|
|
className="h-3.5 w-3.5 rounded border-border"
|
|
/>
|
|
<span className="truncate">{c.name || c.public_key.slice(0, 12)}</span>
|
|
<span className="text-[0.625rem] text-muted-foreground ml-auto font-mono shrink-0">
|
|
{c.public_key.slice(0, 12)}
|
|
</span>
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-base font-semibold tracking-tight">Telemetry Tracked Repeaters</h3>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Each selected repeater becomes an HA device with sensors for battery voltage, RSSI, SNR,
|
|
noise floor, packet counts, and uptime. Data updates whenever telemetry is collected
|
|
(auto-collect runs every ~8 hours, or on manual dashboard fetch). Only repeaters already
|
|
in the auto-telemetry tracking list appear here (add new repeaters by logging into the
|
|
repeater and opting in at the bottom of the page).
|
|
</p>
|
|
{trackedRepeaters.length === 0 ? (
|
|
<div className="rounded-md border border-muted bg-muted/30 px-3 py-2 text-[0.8125rem] text-muted-foreground">
|
|
No repeaters are being auto-tracked for telemetry. Add repeaters to the auto-telemetry
|
|
tracking list in the Radio section first, then return here to select which ones to
|
|
expose to HA.
|
|
</div>
|
|
) : repeaterOptions.length === 0 ? (
|
|
<p className="text-[0.8125rem] text-muted-foreground italic">
|
|
Auto-tracked repeaters not found in contact list.
|
|
</p>
|
|
) : (
|
|
<div className="max-h-40 overflow-y-auto space-y-1 rounded border border-border p-2">
|
|
{repeaterOptions.map((c) => (
|
|
<label key={c.public_key} className="flex items-center gap-2 cursor-pointer text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedRepeaters.includes(c.public_key)}
|
|
onChange={() => toggleTrackedRepeater(c.public_key)}
|
|
className="h-3.5 w-3.5 rounded border-border"
|
|
/>
|
|
<span className="truncate">{c.name || c.public_key.slice(0, 12)}</span>
|
|
<span className="text-[0.625rem] text-muted-foreground ml-auto font-mono">
|
|
{c.public_key.slice(0, 12)}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-base font-semibold tracking-tight">Message Events</h3>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Matching messages fire an{' '}
|
|
<code className="text-[0.6875rem]">{`event.meshcore_${localRadioNodeId}_messages`}</code>{' '}
|
|
entity in HA with sender, text, channel, and direction attributes. Use HA automations to
|
|
trigger actions on specific messages, channels, or contacts.
|
|
</p>
|
|
</div>
|
|
<ScopeSelector scope={scope} onChange={onScopeChange} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MqttCommunityConfigEditor({
|
|
config,
|
|
onChange,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
}) {
|
|
const authMode = (config.auth_mode as string) || DEFAULT_COMMUNITY_AUTH_MODE;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Advanced community MQTT editor. Use this for manual meshcoretomqtt-compatible setups or for
|
|
modifying a saved preset after creation. Only raw RF packets are shared — never
|
|
decrypted messages.
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-host">Broker Host</Label>
|
|
<Input
|
|
id="fanout-comm-host"
|
|
type="text"
|
|
placeholder={DEFAULT_COMMUNITY_BROKER_HOST}
|
|
value={(config.broker_host as string | undefined) ?? ''}
|
|
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-port">Broker Port</Label>
|
|
<Input
|
|
id="fanout-comm-port"
|
|
type="number"
|
|
min="1"
|
|
max="65535"
|
|
value={getNumberInputValue(config.broker_port, DEFAULT_COMMUNITY_BROKER_PORT)}
|
|
onChange={(e) =>
|
|
onChange({
|
|
...config,
|
|
broker_port: parseIntegerInputValue(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-transport">Transport</Label>
|
|
<select
|
|
id="fanout-comm-transport"
|
|
value={(config.transport as string) || DEFAULT_COMMUNITY_TRANSPORT}
|
|
onChange={(e) => onChange({ ...config, transport: e.target.value })}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
<option value="websockets">WebSockets</option>
|
|
<option value="tcp">TCP</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-auth-mode">Authentication</Label>
|
|
<select
|
|
id="fanout-comm-auth-mode"
|
|
value={authMode}
|
|
onChange={(e) => onChange({ ...config, auth_mode: e.target.value })}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
<option value="token">Token</option>
|
|
<option value="none">None</option>
|
|
<option value="password">Username / Password</option>
|
|
</select>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{((config.transport as string) || DEFAULT_COMMUNITY_TRANSPORT) === 'websockets' && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-ws-path">WebSocket Path</Label>
|
|
<Input
|
|
id="fanout-comm-ws-path"
|
|
type="text"
|
|
placeholder="/"
|
|
value={(config.websocket_path as string | undefined) ?? ''}
|
|
onChange={(e) => onChange({ ...config, websocket_path: e.target.value })}
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Defaults to <code>/</code> — use <code>/mqtt</code> for brokers that require a path
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{authMode === 'token' && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-token-audience">Token Audience</Label>
|
|
<Input
|
|
id="fanout-comm-token-audience"
|
|
type="text"
|
|
placeholder={(config.broker_host as string) || DEFAULT_COMMUNITY_BROKER_HOST}
|
|
value={(config.token_audience as string | undefined) ?? ''}
|
|
onChange={(e) => onChange({ ...config, token_audience: e.target.value })}
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Defaults to the broker host when blank
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-email">Owner Email (optional)</Label>
|
|
<Input
|
|
id="fanout-comm-email"
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
value={(config.email as string) || ''}
|
|
onChange={(e) => onChange({ ...config, email: e.target.value })}
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Used to claim your node on the community aggregator
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{authMode === 'password' && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-username">Username</Label>
|
|
<Input
|
|
id="fanout-comm-username"
|
|
type="text"
|
|
value={(config.username as string) || ''}
|
|
onChange={(e) => onChange({ ...config, username: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-password">Password</Label>
|
|
<Input
|
|
id="fanout-comm-password"
|
|
type="password"
|
|
value={(config.password as string) || ''}
|
|
onChange={(e) => onChange({ ...config, password: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={config.use_tls === undefined ? true : !!config.use_tls}
|
|
onChange={(e) => onChange({ ...config, use_tls: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<span className="text-sm">Use TLS</span>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer ml-7">
|
|
<input
|
|
type="checkbox"
|
|
checked={config.tls_verify === undefined ? true : !!config.tls_verify}
|
|
onChange={(e) => onChange({ ...config, tls_verify: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
disabled={config.use_tls === undefined ? false : !config.use_tls}
|
|
/>
|
|
<span className="text-sm">Verify TLS certificates</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-iata">Region Code (IATA)</Label>
|
|
<Input
|
|
id="fanout-comm-iata"
|
|
type="text"
|
|
maxLength={3}
|
|
placeholder="e.g. DEN, LAX, NYC"
|
|
value={(config.iata as string) || ''}
|
|
onChange={(e) => onChange({ ...config, iata: e.target.value.toUpperCase() })}
|
|
className="w-32"
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Your nearest airport's IATA code (required)
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-comm-topic-template">Packet Topic Template</Label>
|
|
<Input
|
|
id="fanout-comm-topic-template"
|
|
type="text"
|
|
placeholder={DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
|
|
value={(config.topic_template as string | undefined) ?? ''}
|
|
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Use <code>{'{IATA}'}</code> and <code>{'{PUBLIC_KEY}'}</code>. Default:{' '}
|
|
<code>{DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}</code>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MeshRankConfigEditor({
|
|
config,
|
|
onChange,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Pre-filled MeshRank setup. This saves as a regular Community MQTT integration once created,
|
|
but only asks for the MeshRank packet topic you were given.
|
|
</p>
|
|
|
|
<div className="rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
|
Broker <code>{DEFAULT_MESHRANK_BROKER_HOST}</code> on port{' '}
|
|
<code>{DEFAULT_MESHRANK_BROKER_PORT}</code> via <code>{DEFAULT_MESHRANK_TRANSPORT}</code>,
|
|
auth <code>{DEFAULT_MESHRANK_AUTH_MODE}</code>, TLS on, certificate verification on, region
|
|
code fixed to <code>{DEFAULT_MESHRANK_IATA}</code>.
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-meshrank-topic-template">Packet Topic Template</Label>
|
|
<Input
|
|
id="fanout-meshrank-topic-template"
|
|
type="text"
|
|
placeholder="meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{PUBLIC_KEY}/packets"
|
|
value={(config.topic_template as string) || ''}
|
|
onChange={(e) =>
|
|
onChange({
|
|
...config,
|
|
iata: DEFAULT_MESHRANK_IATA,
|
|
topic_template: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Paste the full topic template from your MeshRank config, for example{' '}
|
|
<code>meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{'{PUBLIC_KEY}'}/packets</code>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LetsMeshConfigEditor({
|
|
config,
|
|
onChange,
|
|
brokerHost,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
brokerHost: string;
|
|
}) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Pre-filled LetsMesh setup. This saves as a regular Community MQTT integration once created,
|
|
but only asks for the values LetsMesh expects from you.
|
|
</p>
|
|
|
|
<div className="rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
|
Broker <code>{brokerHost}</code> on port <code>{DEFAULT_COMMUNITY_BROKER_PORT}</code> via{' '}
|
|
<code>{DEFAULT_COMMUNITY_TRANSPORT}</code>, auth <code>{DEFAULT_COMMUNITY_AUTH_MODE}</code>,
|
|
TLS on, certificate verification on, token audience fixed to <code>{brokerHost}</code>.
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-letsmesh-email">Email</Label>
|
|
<Input
|
|
id="fanout-letsmesh-email"
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
value={(config.email as string) || ''}
|
|
onChange={(e) =>
|
|
onChange({ ...config, email: e.target.value, broker_host: brokerHost })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-letsmesh-iata">Region Code (IATA)</Label>
|
|
<Input
|
|
id="fanout-letsmesh-iata"
|
|
type="text"
|
|
maxLength={3}
|
|
placeholder="e.g. DEN, LAX, NYC"
|
|
value={(config.iata as string) || ''}
|
|
onChange={(e) =>
|
|
onChange({
|
|
...config,
|
|
broker_host: brokerHost,
|
|
token_audience: brokerHost,
|
|
iata: e.target.value.toUpperCase(),
|
|
})
|
|
}
|
|
className="w-32"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BotConfigEditor({
|
|
config,
|
|
onChange,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
}) {
|
|
const code = (config.code as string) || '';
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="p-3 bg-destructive/10 border border-destructive/30 rounded-md">
|
|
<p className="text-sm text-destructive">
|
|
<strong>Experimental:</strong> This is an alpha feature and introduces automated message
|
|
sending to your radio; unexpected behavior may occur. Use with caution, and please report
|
|
any bugs!
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
|
|
<p className="text-sm text-warning">
|
|
<strong>Security Warning:</strong> This feature executes arbitrary Python code on the
|
|
server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
|
|
<p className="text-sm text-warning">
|
|
<strong>Don't wreck the mesh!</strong> Bots process ALL messages, including their
|
|
own. Be careful of creating infinite loops!
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Define a <code className="bg-muted px-1 rounded">bot()</code> function that receives
|
|
message data and optionally returns a reply.
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onChange({ ...config, code: DEFAULT_BOT_CODE })}
|
|
>
|
|
Reset to Example
|
|
</Button>
|
|
</div>
|
|
|
|
<Suspense
|
|
fallback={
|
|
<div className="h-64 md:h-96 rounded-md border border-input bg-code-editor-bg flex items-center justify-center text-muted-foreground">
|
|
Loading editor...
|
|
</div>
|
|
}
|
|
>
|
|
<BotCodeEditor value={code} onChange={(c) => onChange({ ...config, code: c })} />
|
|
</Suspense>
|
|
|
|
<div className="text-[0.8125rem] text-muted-foreground space-y-1">
|
|
<p>
|
|
<strong>Available:</strong> Standard Python libraries and any modules installed in the
|
|
server environment.
|
|
</p>
|
|
<p>
|
|
<strong>Limits:</strong> 10 second timeout per bot.
|
|
</p>
|
|
<p>
|
|
<strong>Note:</strong> Bots respond to all messages, including your own. For channel
|
|
messages, <code>sender_key</code> is <code>None</code>. Multiple enabled bots run
|
|
concurrently. Outgoing messages are serialized with a two-second delay between sends to
|
|
prevent repeater collision.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapUploadConfigEditor({
|
|
config,
|
|
onChange,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
}) {
|
|
const isDryRun = config.dry_run !== false;
|
|
const [radioLat, setRadioLat] = useState<number | null>(null);
|
|
const [radioLon, setRadioLon] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
api
|
|
.getRadioConfig()
|
|
.then((rc) => {
|
|
setRadioLat(rc.lat ?? 0);
|
|
setRadioLon(rc.lon ?? 0);
|
|
})
|
|
.catch(() => {
|
|
setRadioLat(0);
|
|
setRadioLon(0);
|
|
});
|
|
}, []);
|
|
|
|
const radioLatLonConfigured =
|
|
radioLat !== null && radioLon !== null && !(radioLat === 0 && radioLon === 0);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Automatically upload heard repeater and room server advertisements to{' '}
|
|
<a
|
|
href="https://map.meshcore.io"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline hover:text-foreground"
|
|
>
|
|
map.meshcore.io
|
|
</a>
|
|
. Requires the radio's private key to be available (firmware must have{' '}
|
|
<code>ENABLE_PRIVATE_KEY_EXPORT=1</code>). Only raw RF packets are shared — never
|
|
decrypted messages.
|
|
</p>
|
|
|
|
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
|
<strong>Dry Run is {isDryRun ? 'ON' : 'OFF'}.</strong>{' '}
|
|
{isDryRun
|
|
? 'No uploads will be sent. Check the backend logs to verify the payload looks correct before enabling live sends.'
|
|
: 'Live uploads are enabled. Each advert is rate-limited to once per hour per node.'}
|
|
</div>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={isDryRun}
|
|
onChange={(e) => onChange({ ...config, dry_run: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<div>
|
|
<span className="text-sm font-medium">Dry Run (log only, no uploads)</span>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
When enabled, upload payloads are logged at INFO level but not sent. Disable once you
|
|
have confirmed the logged output looks correct.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-map-api-url">API URL (optional)</Label>
|
|
<Input
|
|
id="fanout-map-api-url"
|
|
type="url"
|
|
placeholder="https://map.meshcore.io/api/v1/uploader/node"
|
|
value={(config.api_url as string) || ''}
|
|
onChange={(e) => onChange({ ...config, api_url: e.target.value })}
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Leave blank to use the default <code>map.meshcore.io</code> endpoint.
|
|
</p>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!config.geofence_enabled}
|
|
onChange={(e) => onChange({ ...config, geofence_enabled: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<div>
|
|
<span className="text-sm font-medium">Enable Geofence</span>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Only upload nodes whose location falls within the configured radius of your radio's
|
|
own position. Helps exclude nodes with false or spoofed coordinates. Uses the
|
|
latitude/longitude set in Radio Settings.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
{!!config.geofence_enabled && (
|
|
<div className="space-y-3 pl-7">
|
|
{!radioLatLonConfigured && (
|
|
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
|
Your radio does not currently have a latitude/longitude configured. Geofencing will be
|
|
silently skipped until coordinates are set in{' '}
|
|
<strong>Settings → Radio → Location</strong>.
|
|
</div>
|
|
)}
|
|
{radioLatLonConfigured && (
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Using radio position{' '}
|
|
<code>
|
|
{radioLat?.toFixed(5)}, {radioLon?.toFixed(5)}
|
|
</code>{' '}
|
|
as the geofence center. Update coordinates in Radio Settings to move the center.
|
|
</p>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-map-geofence-radius">Radius (km)</Label>
|
|
<Input
|
|
id="fanout-map-geofence-radius"
|
|
type="number"
|
|
min="0"
|
|
step="any"
|
|
placeholder="e.g. 100"
|
|
value={getOptionalNumberInputValue(config.geofence_radius_km)}
|
|
onChange={(e) =>
|
|
onChange({
|
|
...config,
|
|
geofence_radius_km: parseFloatInputValue(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Nodes further than this distance from your radio's position will not be uploaded.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ScopeMode = 'all' | 'none' | 'only' | 'except';
|
|
|
|
function getScopeMode(value: unknown): ScopeMode {
|
|
if (value === 'all') return 'all';
|
|
if (value === 'none') return 'none';
|
|
if (typeof value === 'object' && value !== null) {
|
|
// Check if either channels or contacts uses the {except: [...]} shape
|
|
const obj = value as Record<string, unknown>;
|
|
const ch = obj.channels;
|
|
const co = obj.contacts;
|
|
if (
|
|
(typeof ch === 'object' && ch !== null && !Array.isArray(ch)) ||
|
|
(typeof co === 'object' && co !== null && !Array.isArray(co))
|
|
) {
|
|
return 'except';
|
|
}
|
|
return 'only';
|
|
}
|
|
return 'all';
|
|
}
|
|
|
|
/** Extract the key list from a filter value, whether it's a plain list or {except: [...]} */
|
|
function getFilterKeys(filter: unknown): string[] {
|
|
if (Array.isArray(filter)) return filter as string[];
|
|
if (typeof filter === 'object' && filter !== null && 'except' in filter)
|
|
return ((filter as Record<string, unknown>).except as string[]) ?? [];
|
|
return [];
|
|
}
|
|
|
|
const MAX_SCOPE_PILL_DISPLAY = 32;
|
|
|
|
interface PillsSearchListItem {
|
|
key: string;
|
|
label: string;
|
|
/** Optional trailing monospace hint (e.g. pubkey prefix) */
|
|
trailing?: string;
|
|
}
|
|
|
|
/**
|
|
* Search-and-pills picker for the generic fanout scope selector.
|
|
* Shows selected items as removable pills (up to MAX_SCOPE_PILL_DISPLAY),
|
|
* a search input, and a scrollable list of filtered items with checkboxes.
|
|
* When more than MAX_SCOPE_PILL_DISPLAY items are selected, the pill row
|
|
* collapses to a single informational badge to keep the interface clean.
|
|
*/
|
|
function PillsSearchList({
|
|
label,
|
|
labelSuffix,
|
|
items,
|
|
selectedKeys,
|
|
onToggle,
|
|
onAll,
|
|
onNone,
|
|
searchPlaceholder,
|
|
emptyItemsMessage,
|
|
}: {
|
|
label: string;
|
|
labelSuffix: string;
|
|
items: PillsSearchListItem[];
|
|
selectedKeys: string[];
|
|
onToggle: (key: string) => void;
|
|
onAll: () => void;
|
|
onNone: () => void;
|
|
searchPlaceholder: string;
|
|
emptyItemsMessage: string;
|
|
}) {
|
|
const [search, setSearch] = useState('');
|
|
const searchLower = search.toLowerCase().trim();
|
|
|
|
const filtered = useMemo(() => {
|
|
const matches = items.filter((it) => {
|
|
if (!searchLower) return true;
|
|
return (
|
|
it.label.toLowerCase().includes(searchLower) || it.key.toLowerCase().startsWith(searchLower)
|
|
);
|
|
});
|
|
// Selected items sort to top (mirrors the Home Assistant tracked-contacts picker)
|
|
return matches.sort((a, b) => {
|
|
const aSel = selectedKeys.includes(a.key) ? 0 : 1;
|
|
const bSel = selectedKeys.includes(b.key) ? 0 : 1;
|
|
if (aSel !== bSel) return aSel - bSel;
|
|
return a.label.localeCompare(b.label);
|
|
});
|
|
}, [items, searchLower, selectedKeys]);
|
|
|
|
const selectedDetails = useMemo(
|
|
() => items.filter((it) => selectedKeys.includes(it.key)),
|
|
[items, selectedKeys]
|
|
);
|
|
const overPillLimit = selectedDetails.length > MAX_SCOPE_PILL_DISPLAY;
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">
|
|
{label} <span className="text-muted-foreground font-normal">({labelSuffix})</span>
|
|
</Label>
|
|
<span className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={onAll}
|
|
>
|
|
All
|
|
</button>
|
|
<span className="text-xs text-muted-foreground">/</span>
|
|
<button
|
|
type="button"
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={onNone}
|
|
>
|
|
None
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
{selectedDetails.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{overPillLimit ? (
|
|
<span className="inline-flex items-center text-[0.6875rem] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
|
>{MAX_SCOPE_PILL_DISPLAY} selections made; hiding selection preview to keep the
|
|
interface clean
|
|
</span>
|
|
) : (
|
|
selectedDetails.map((it) => (
|
|
<span
|
|
key={it.key}
|
|
className="inline-flex items-center gap-1 text-[0.6875rem] px-2 py-0.5 rounded-full bg-primary/10 text-primary"
|
|
>
|
|
{it.label}
|
|
<button
|
|
type="button"
|
|
className="ml-0.5 hover:text-destructive transition-colors"
|
|
onClick={() => onToggle(it.key)}
|
|
aria-label={`Remove ${it.label}`}
|
|
>
|
|
×
|
|
</button>
|
|
</span>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{items.length === 0 ? (
|
|
<p className="text-[0.8125rem] text-muted-foreground italic">{emptyItemsMessage}</p>
|
|
) : (
|
|
<>
|
|
<Input
|
|
type="text"
|
|
placeholder={searchPlaceholder}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="h-8 text-sm"
|
|
/>
|
|
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
|
|
{filtered.length === 0 ? (
|
|
<p className="text-[0.8125rem] text-muted-foreground italic py-1">
|
|
No {label.toLowerCase()} match “{search}”
|
|
</p>
|
|
) : (
|
|
filtered.map((it) => (
|
|
<label key={it.key} className="flex items-center gap-2 cursor-pointer text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedKeys.includes(it.key)}
|
|
onChange={() => onToggle(it.key)}
|
|
className="h-3.5 w-3.5 rounded border-input accent-primary"
|
|
/>
|
|
<span className="truncate">{it.label}</span>
|
|
{it.trailing && (
|
|
<span className="text-[0.625rem] text-muted-foreground ml-auto font-mono shrink-0">
|
|
{it.trailing}
|
|
</span>
|
|
)}
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScopeSelector({
|
|
scope,
|
|
onChange,
|
|
showRawPackets = false,
|
|
}: {
|
|
scope: Record<string, unknown>;
|
|
onChange: (scope: Record<string, unknown>) => void;
|
|
showRawPackets?: boolean;
|
|
}) {
|
|
const [channels, setChannels] = useState<Channel[]>([]);
|
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
|
|
useEffect(() => {
|
|
api.getChannels().then(setChannels).catch(console.error);
|
|
|
|
// Paginate to fetch all contacts (API caps at 1000 per request)
|
|
(async () => {
|
|
const all: Contact[] = [];
|
|
const pageSize = 1000;
|
|
let offset = 0;
|
|
|
|
while (true) {
|
|
const page = await api.getContacts(pageSize, offset);
|
|
all.push(...page);
|
|
if (page.length < pageSize) break;
|
|
offset += pageSize;
|
|
}
|
|
setContacts(all);
|
|
})().catch(console.error);
|
|
}, []);
|
|
|
|
const messages = scope.messages ?? 'all';
|
|
const rawMode = getScopeMode(messages);
|
|
// When raw packets aren't offered, "none" is not a valid choice — treat as "all"
|
|
const mode = !showRawPackets && rawMode === 'none' ? 'all' : rawMode;
|
|
const isListMode = mode === 'only' || mode === 'except';
|
|
|
|
const selectedChannels: string[] =
|
|
isListMode && typeof messages === 'object' && messages !== null
|
|
? getFilterKeys((messages as Record<string, unknown>).channels)
|
|
: [];
|
|
const selectedContacts: string[] =
|
|
isListMode && typeof messages === 'object' && messages !== null
|
|
? getFilterKeys((messages as Record<string, unknown>).contacts)
|
|
: [];
|
|
|
|
/** Wrap channel/contact key lists in the right shape for the current mode */
|
|
const buildMessages = (chKeys: string[], coKeys: string[]) => {
|
|
if (mode === 'except') {
|
|
return {
|
|
channels: { except: chKeys },
|
|
contacts: { except: coKeys },
|
|
};
|
|
}
|
|
return { channels: chKeys, contacts: coKeys };
|
|
};
|
|
|
|
const handleModeChange = (newMode: ScopeMode) => {
|
|
if (newMode === 'all' || newMode === 'none') {
|
|
onChange({ ...scope, messages: newMode });
|
|
} else if (newMode === 'only') {
|
|
onChange({ ...scope, messages: { channels: [], contacts: [] } });
|
|
} else {
|
|
onChange({
|
|
...scope,
|
|
messages: { channels: { except: [] }, contacts: { except: [] } },
|
|
});
|
|
}
|
|
};
|
|
|
|
const toggleChannel = (key: string) => {
|
|
const current = [...selectedChannels];
|
|
const idx = current.indexOf(key);
|
|
if (idx >= 0) current.splice(idx, 1);
|
|
else current.push(key);
|
|
onChange({ ...scope, messages: buildMessages(current, selectedContacts) });
|
|
};
|
|
|
|
const toggleContact = (key: string) => {
|
|
const current = [...selectedContacts];
|
|
const idx = current.indexOf(key);
|
|
if (idx >= 0) current.splice(idx, 1);
|
|
else current.push(key);
|
|
onChange({ ...scope, messages: buildMessages(selectedChannels, current) });
|
|
};
|
|
|
|
// Exclude repeaters (2), rooms (3), and sensors (4)
|
|
const filteredContacts = contacts.filter((c) => c.type === 0 || c.type === 1);
|
|
|
|
const modeDescriptions: Record<ScopeMode, string> = {
|
|
all: 'All messages',
|
|
none: 'No messages',
|
|
only: 'Only listed channels/contacts',
|
|
except: 'All except listed channels/contacts',
|
|
};
|
|
|
|
const rawEnabled = showRawPackets && scope.raw_packets === 'all';
|
|
|
|
// Warn when the effective scope matches nothing
|
|
const messagesEffectivelyNone =
|
|
mode === 'none' ||
|
|
(mode === 'only' && selectedChannels.length === 0 && selectedContacts.length === 0) ||
|
|
(mode === 'except' &&
|
|
channels.length > 0 &&
|
|
filteredContacts.length > 0 &&
|
|
selectedChannels.length >= channels.length &&
|
|
selectedContacts.length >= filteredContacts.length);
|
|
const showEmptyScopeWarning = messagesEffectivelyNone && !rawEnabled;
|
|
|
|
const listHint =
|
|
mode === 'only'
|
|
? 'Newly added channels or contacts will not be automatically included.'
|
|
: 'Newly added channels or contacts will be automatically included unless excluded here.';
|
|
|
|
const checkboxLabel = mode === 'except' ? 'exclude' : 'include';
|
|
|
|
const messageModes: ScopeMode[] = showRawPackets
|
|
? ['all', 'none', 'only', 'except']
|
|
: ['all', 'only', 'except'];
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<h3 className="text-base font-semibold tracking-tight">Message Scope</h3>
|
|
|
|
{showRawPackets && (
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={rawEnabled}
|
|
onChange={(e) => onChange({ ...scope, raw_packets: e.target.checked ? 'all' : 'none' })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<span className="text-sm">Forward raw packets</span>
|
|
</label>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
{messageModes.map((m) => (
|
|
<label key={m} className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="scope-mode"
|
|
checked={mode === m}
|
|
onChange={() => handleModeChange(m)}
|
|
className="h-4 w-4 accent-primary"
|
|
/>
|
|
<span className="text-sm">{modeDescriptions[m]}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{showEmptyScopeWarning && (
|
|
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
|
Nothing is selected — this integration will not forward any data.
|
|
</div>
|
|
)}
|
|
|
|
{isListMode && (
|
|
<>
|
|
<p className="text-[0.8125rem] text-muted-foreground">{listHint}</p>
|
|
|
|
{channels.length > 0 && (
|
|
<PillsSearchList
|
|
label="Channels"
|
|
labelSuffix={checkboxLabel}
|
|
items={channels.map((ch) => ({ key: ch.key, label: ch.name }))}
|
|
selectedKeys={selectedChannels}
|
|
onToggle={toggleChannel}
|
|
onAll={() =>
|
|
onChange({
|
|
...scope,
|
|
messages: buildMessages(
|
|
channels.map((ch) => ch.key),
|
|
selectedContacts
|
|
),
|
|
})
|
|
}
|
|
onNone={() => onChange({ ...scope, messages: buildMessages([], selectedContacts) })}
|
|
searchPlaceholder={`Search ${channels.length} channel${channels.length === 1 ? '' : 's'}...`}
|
|
emptyItemsMessage="No channels available."
|
|
/>
|
|
)}
|
|
|
|
{filteredContacts.length > 0 && (
|
|
<PillsSearchList
|
|
label="Contacts"
|
|
labelSuffix={checkboxLabel}
|
|
items={filteredContacts.map((c) => ({
|
|
key: c.public_key,
|
|
label: c.name || c.public_key.slice(0, 12),
|
|
trailing: c.public_key.slice(0, 12),
|
|
}))}
|
|
selectedKeys={selectedContacts}
|
|
onToggle={toggleContact}
|
|
onAll={() =>
|
|
onChange({
|
|
...scope,
|
|
messages: buildMessages(
|
|
selectedChannels,
|
|
filteredContacts.map((c) => c.public_key)
|
|
),
|
|
})
|
|
}
|
|
onNone={() => onChange({ ...scope, messages: buildMessages(selectedChannels, []) })}
|
|
searchPlaceholder={`Search ${filteredContacts.length} contact${filteredContacts.length === 1 ? '' : 's'}...`}
|
|
emptyItemsMessage="No contacts available."
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const APPRISE_DEFAULT_DM = '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
|
const APPRISE_DEFAULT_CHANNEL =
|
|
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
|
const APPRISE_DEFAULT_DM_PLAIN = 'DM: {sender_name}: {text} via: [{hops}]';
|
|
const APPRISE_DEFAULT_CHANNEL_PLAIN = '{channel_name}: {sender_name}: {text} via: [{hops}]';
|
|
|
|
const APPRISE_SAMPLE_VARS: Record<string, string> = {
|
|
type: 'CHAN',
|
|
text: 'hello world',
|
|
sender_name: 'Alice',
|
|
sender_key: 'a1b2c3d4e5f6',
|
|
channel_name: '#general',
|
|
conversation_key: 'abcdef1234567890',
|
|
hops: '2a, 3b',
|
|
hops_backticked: '`2a`, `3b`',
|
|
hop_count: '2',
|
|
rssi: '-95',
|
|
snr: '6.5',
|
|
};
|
|
|
|
const APPRISE_SAMPLE_VARS_DM: Record<string, string> = {
|
|
...APPRISE_SAMPLE_VARS,
|
|
type: 'PRIV',
|
|
channel_name: '',
|
|
conversation_key: 'a1b2c3d4e5f6',
|
|
};
|
|
|
|
function appriseApplyFormat(fmt: string, vars: Record<string, string>): string {
|
|
let result = fmt;
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
result = result.split(`{${key}}`).join(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Render a markdown-ish string into inline React elements (bold, italic, code). */
|
|
function appriseRenderMarkdown(s: string): ReactNode[] {
|
|
const nodes: ReactNode[] = [];
|
|
let key = 0;
|
|
// Split on **bold**, __bold__, *italic*, _italic_, and `code` spans.
|
|
// Longer delimiters first so ** and __ match before * and _.
|
|
const parts = s.split(/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_)/g);
|
|
for (const part of parts) {
|
|
if (
|
|
(part.startsWith('**') && part.endsWith('**')) ||
|
|
(part.startsWith('__') && part.endsWith('__'))
|
|
) {
|
|
nodes.push(
|
|
<strong key={key++} className="font-bold">
|
|
{part.slice(2, -2)}
|
|
</strong>
|
|
);
|
|
} else if (
|
|
(part.startsWith('*') && part.endsWith('*')) ||
|
|
(part.startsWith('_') && part.endsWith('_'))
|
|
) {
|
|
nodes.push(
|
|
<em key={key++} className="italic">
|
|
{part.slice(1, -1)}
|
|
</em>
|
|
);
|
|
} else if (part.startsWith('`') && part.endsWith('`')) {
|
|
nodes.push(
|
|
<code key={key++} className="rounded bg-muted px-1 py-0.5 text-[0.6875rem] font-mono">
|
|
{part.slice(1, -1)}
|
|
</code>
|
|
);
|
|
} else if (part) {
|
|
nodes.push(<span key={key++}>{part}</span>);
|
|
}
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
function AppriseFormatPreview({
|
|
format,
|
|
vars,
|
|
markdown = true,
|
|
}: {
|
|
format: string;
|
|
vars: Record<string, string>;
|
|
markdown?: boolean;
|
|
}) {
|
|
const raw = appriseApplyFormat(format, vars);
|
|
return (
|
|
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
|
|
{markdown && (
|
|
<div>
|
|
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
|
Rendered (Discord, Slack, Telegram)
|
|
</span>
|
|
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
|
{markdown ? 'Raw (email, SMS)' : 'Preview'}
|
|
</span>
|
|
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function appriseIsDefault(value: unknown, defaultStr: string): boolean {
|
|
if (value == null) return true;
|
|
const s = String(value).trim();
|
|
return s === '' || s === defaultStr;
|
|
}
|
|
|
|
function AppriseConfigEditor({
|
|
config,
|
|
scope,
|
|
onChange,
|
|
onScopeChange,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
scope: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
onScopeChange: (scope: Record<string, unknown>) => void;
|
|
}) {
|
|
const markdown = config.markdown_format !== false;
|
|
const defaultDm = markdown ? APPRISE_DEFAULT_DM : APPRISE_DEFAULT_DM_PLAIN;
|
|
const defaultChan = markdown ? APPRISE_DEFAULT_CHANNEL : APPRISE_DEFAULT_CHANNEL_PLAIN;
|
|
const dmFormat = ((config.body_format_dm as string) || '').trim() || defaultDm;
|
|
const chanFormat = ((config.body_format_channel as string) || '').trim() || defaultChan;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Send push notifications via{' '}
|
|
<a
|
|
href="https://github.com/caronc/apprise"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline hover:text-foreground"
|
|
>
|
|
Apprise
|
|
</a>{' '}
|
|
when messages are received. Supports Discord, Slack, Telegram, email, and{' '}
|
|
<a
|
|
href="https://github.com/caronc/apprise/wiki#supported-notifications"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline hover:text-foreground"
|
|
>
|
|
100+ other services
|
|
</a>
|
|
.
|
|
</p>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-apprise-urls">Notification URLs</Label>
|
|
<textarea
|
|
id="fanout-apprise-urls"
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[80px]"
|
|
placeholder={
|
|
'discord://webhook_id/token\nslack://token_a/token_b/token_c\ntgram://bot_token/chat_id'
|
|
}
|
|
value={(config.urls as string) || ''}
|
|
onChange={(e) => onChange({ ...config, urls: e.target.value })}
|
|
rows={4}
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
One URL per line. All URLs receive every matched notification. For Matrix room version 12
|
|
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
|
|
</p>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={config.preserve_identity !== false}
|
|
onChange={(e) => onChange({ ...config, preserve_identity: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<div>
|
|
<span className="text-sm">Preserve identity on Discord</span>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
When enabled, Discord webhooks will use their configured name/avatar instead of
|
|
overriding with MeshCore sender info.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={config.include_outgoing === true}
|
|
onChange={(e) => onChange({ ...config, include_outgoing: e.target.checked })}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<div>
|
|
<span className="text-sm">Forward RemoteTerm-sent messages</span>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Include DMs and channel messages sent by this RemoteTerm instance, including manual
|
|
sends and bot replies. Outgoing messages carry no routing path or signal data, so
|
|
path-related format fields render as direct and RSSI/SNR are empty.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
<Separator />
|
|
|
|
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={markdown}
|
|
onChange={(e) => {
|
|
const md = e.target.checked;
|
|
const updates: Record<string, unknown> = { ...config, markdown_format: md };
|
|
const curDm = ((config.body_format_dm as string) || '').trim();
|
|
const curChan = ((config.body_format_channel as string) || '').trim();
|
|
if (md) {
|
|
if (!curDm || curDm === APPRISE_DEFAULT_DM_PLAIN)
|
|
updates.body_format_dm = APPRISE_DEFAULT_DM;
|
|
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL_PLAIN)
|
|
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL;
|
|
} else {
|
|
if (!curDm || curDm === APPRISE_DEFAULT_DM)
|
|
updates.body_format_dm = APPRISE_DEFAULT_DM_PLAIN;
|
|
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL)
|
|
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL_PLAIN;
|
|
}
|
|
onChange(updates);
|
|
}}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
<div>
|
|
<span className="text-sm">Markdown formatting</span>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
If notifications fail on services like Telegram due to special characters in sender
|
|
names, disable this option.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
<details className="group">
|
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
|
Available variables
|
|
</summary>
|
|
<div className="mt-2 rounded-md border border-border bg-muted/30 p-2 text-xs space-y-0.5">
|
|
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{text}'}</code>
|
|
<span className="text-muted-foreground">Message body</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
|
{'{sender_name}'}
|
|
</code>
|
|
<span className="text-muted-foreground">Sender display name</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
|
{'{sender_key}'}
|
|
</code>
|
|
<span className="text-muted-foreground">Sender public key (hex)</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
|
{'{channel_name}'}
|
|
</code>
|
|
<span className="text-muted-foreground">Channel name (channel messages only)</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
|
{'{conversation_key}'}
|
|
</code>
|
|
<span className="text-muted-foreground">
|
|
Contact pubkey (DM) or channel key (channel)
|
|
</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{type}'}</code>
|
|
<span className="text-muted-foreground">PRIV or CHAN</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{hops}'}</code>
|
|
<span className="text-muted-foreground">
|
|
Comma-separated hop IDs, or "direct"
|
|
</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
|
{'{hops_backticked}'}
|
|
</code>
|
|
<span className="text-muted-foreground">Hops wrapped in backticks for markdown</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
|
{'{hop_count}'}
|
|
</code>
|
|
<span className="text-muted-foreground">Number of hops (0 for direct)</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{rssi}'}</code>
|
|
<span className="text-muted-foreground">Last-hop RSSI in dBm</span>
|
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{snr}'}</code>
|
|
<span className="text-muted-foreground">Last-hop SNR in dB</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1.5">
|
|
Empty textareas use the default format. RSSI/SNR may be empty if unavailable.
|
|
</p>
|
|
</div>
|
|
</details>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="fanout-apprise-fmt-dm">DM format</Label>
|
|
{!appriseIsDefault(config.body_format_dm, defaultDm) && (
|
|
<button
|
|
type="button"
|
|
aria-label="Reset DM format to default"
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => onChange({ ...config, body_format_dm: defaultDm })}
|
|
>
|
|
Reset to default
|
|
</button>
|
|
)}
|
|
</div>
|
|
<textarea
|
|
id="fanout-apprise-fmt-dm"
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
|
placeholder={defaultDm}
|
|
value={(config.body_format_dm as string) ?? ''}
|
|
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
|
|
rows={2}
|
|
/>
|
|
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} markdown={markdown} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="fanout-apprise-fmt-chan">Channel format</Label>
|
|
{!appriseIsDefault(config.body_format_channel, defaultChan) && (
|
|
<button
|
|
type="button"
|
|
aria-label="Reset channel format to default"
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => onChange({ ...config, body_format_channel: defaultChan })}
|
|
>
|
|
Reset to default
|
|
</button>
|
|
)}
|
|
</div>
|
|
<textarea
|
|
id="fanout-apprise-fmt-chan"
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
|
placeholder={defaultChan}
|
|
value={(config.body_format_channel as string) ?? ''}
|
|
onChange={(e) => onChange({ ...config, body_format_channel: e.target.value })}
|
|
rows={2}
|
|
/>
|
|
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} markdown={markdown} />
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<ScopeSelector scope={scope} onChange={onScopeChange} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WebhookConfigEditor({
|
|
config,
|
|
scope,
|
|
onChange,
|
|
onScopeChange,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
scope: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
onScopeChange: (scope: Record<string, unknown>) => void;
|
|
}) {
|
|
const headersStr = JSON.stringify(config.headers ?? {}, null, 2);
|
|
const [headersText, setHeadersText] = useState(headersStr);
|
|
const [headersError, setHeadersError] = useState<string | null>(null);
|
|
|
|
const handleHeadersChange = (text: string) => {
|
|
setHeadersText(text);
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
setHeadersError('Must be a JSON object');
|
|
return;
|
|
}
|
|
setHeadersError(null);
|
|
onChange({ ...config, headers: parsed });
|
|
} catch {
|
|
setHeadersError('Invalid JSON');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Send message data as JSON to an HTTP endpoint when messages are received.
|
|
</p>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-webhook-url">URL</Label>
|
|
<Input
|
|
id="fanout-webhook-url"
|
|
type="url"
|
|
placeholder="https://example.com/webhook"
|
|
value={(config.url as string) || ''}
|
|
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-webhook-method">HTTP Method</Label>
|
|
<select
|
|
id="fanout-webhook-method"
|
|
value={(config.method as string) || 'POST'}
|
|
onChange={(e) => onChange({ ...config, method: e.target.value })}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
<option value="POST">POST</option>
|
|
<option value="PUT">PUT</option>
|
|
<option value="PATCH">PATCH</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-3">
|
|
<h3 className="text-base font-semibold tracking-tight">HMAC Signing</h3>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
When a secret is set, each request includes an HMAC-SHA256 signature of the JSON body in
|
|
the specified header (e.g. <code className="bg-muted px-1 rounded">sha256=ab12cd...</code>
|
|
).
|
|
</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-webhook-hmac-secret">HMAC Secret</Label>
|
|
<Input
|
|
id="fanout-webhook-hmac-secret"
|
|
type="password"
|
|
placeholder="Leave empty to disable signing"
|
|
value={(config.hmac_secret as string) || ''}
|
|
onChange={(e) => onChange({ ...config, hmac_secret: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-webhook-hmac-header">Signature Header Name</Label>
|
|
<Input
|
|
id="fanout-webhook-hmac-header"
|
|
type="text"
|
|
placeholder="X-Webhook-Signature"
|
|
value={(config.hmac_header as string) || ''}
|
|
onChange={(e) => onChange({ ...config, hmac_header: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-webhook-headers">Extra Headers (JSON)</Label>
|
|
<textarea
|
|
id="fanout-webhook-headers"
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[60px]"
|
|
value={headersText}
|
|
onChange={(e) => handleHeadersChange(e.target.value)}
|
|
placeholder='{"Authorization": "Bearer ..."}'
|
|
/>
|
|
{headersError && <p className="text-xs text-destructive">{headersError}</p>}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<ScopeSelector scope={scope} onChange={onScopeChange} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SqsConfigEditor({
|
|
config,
|
|
scope,
|
|
onChange,
|
|
onScopeChange,
|
|
}: {
|
|
config: Record<string, unknown>;
|
|
scope: Record<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => void;
|
|
onScopeChange: (scope: Record<string, unknown>) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Send matched mesh events to an Amazon SQS queue for durable processing by workers, Lambdas,
|
|
or downstream automation.
|
|
</p>
|
|
|
|
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
|
Outgoing messages and any selected raw packets will be delivered exactly as forwarded by the
|
|
fanout scope, including decrypted/plaintext message content.
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-sqs-queue-url">Queue URL</Label>
|
|
<Input
|
|
id="fanout-sqs-queue-url"
|
|
type="url"
|
|
placeholder="https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"
|
|
value={(config.queue_url as string) || ''}
|
|
onChange={(e) => onChange({ ...config, queue_url: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-sqs-region">Region (optional)</Label>
|
|
<Input
|
|
id="fanout-sqs-region"
|
|
type="text"
|
|
placeholder="us-east-1"
|
|
value={(config.region_name as string) || ''}
|
|
onChange={(e) => onChange({ ...config, region_name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-sqs-endpoint">Endpoint URL (optional)</Label>
|
|
<Input
|
|
id="fanout-sqs-endpoint"
|
|
type="url"
|
|
placeholder="http://localhost:4566"
|
|
value={(config.endpoint_url as string) || ''}
|
|
onChange={(e) => onChange({ ...config, endpoint_url: e.target.value })}
|
|
/>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Useful for LocalStack or custom endpoints
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-base font-semibold tracking-tight">Static Credentials (optional)</h3>
|
|
<p className="text-[0.8125rem] text-muted-foreground">
|
|
Leave blank to use the server's normal AWS credential chain.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-sqs-access-key">Access Key ID</Label>
|
|
<Input
|
|
id="fanout-sqs-access-key"
|
|
type="text"
|
|
value={(config.access_key_id as string) || ''}
|
|
onChange={(e) => onChange({ ...config, access_key_id: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-sqs-secret-key">Secret Access Key</Label>
|
|
<Input
|
|
id="fanout-sqs-secret-key"
|
|
type="password"
|
|
value={(config.secret_access_key as string) || ''}
|
|
onChange={(e) => onChange({ ...config, secret_access_key: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-sqs-session-token">Session Token (optional)</Label>
|
|
<Input
|
|
id="fanout-sqs-session-token"
|
|
type="password"
|
|
value={(config.session_token as string) || ''}
|
|
onChange={(e) => onChange({ ...config, session_token: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<ScopeSelector scope={scope} onChange={onScopeChange} showRawPackets />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SettingsFanoutSection({
|
|
health,
|
|
onHealthRefresh,
|
|
className,
|
|
}: {
|
|
health: HealthStatus | null;
|
|
onHealthRefresh?: () => Promise<void>;
|
|
className?: string;
|
|
}) {
|
|
const [configs, setConfigs] = useState<FanoutConfig[]>([]);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [draftType, setDraftType] = useState<DraftType | null>(null);
|
|
const [editConfig, setEditConfig] = useState<Record<string, unknown>>({});
|
|
const [editScope, setEditScope] = useState<Record<string, unknown>>({});
|
|
const [editName, setEditName] = useState('');
|
|
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
|
const [inlineEditName, setInlineEditName] = useState('');
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
|
|
const [errorDialogState, setErrorDialogState] = useState<{
|
|
integrationName: string;
|
|
error: string;
|
|
} | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const loadConfigs = useCallback(async () => {
|
|
try {
|
|
const data = await api.getFanoutConfigs();
|
|
setConfigs(data);
|
|
} catch (err) {
|
|
console.error('Failed to load fanout configs:', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadConfigs();
|
|
}, [loadConfigs]);
|
|
|
|
const availableCreateOptions = useMemo(
|
|
() =>
|
|
CREATE_INTEGRATION_DEFINITIONS.filter(
|
|
(definition) => definition.savedType !== 'bot' || !health?.bots_disabled
|
|
),
|
|
[health?.bots_disabled]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!createDialogOpen) return;
|
|
if (availableCreateOptions.length === 0) {
|
|
setSelectedCreateType(null);
|
|
return;
|
|
}
|
|
if (
|
|
selectedCreateType &&
|
|
availableCreateOptions.some((option) => option.value === selectedCreateType)
|
|
) {
|
|
return;
|
|
}
|
|
setSelectedCreateType(availableCreateOptions[0].value);
|
|
}, [createDialogOpen, availableCreateOptions, selectedCreateType]);
|
|
|
|
const handleToggleEnabled = async (cfg: FanoutConfig) => {
|
|
try {
|
|
await api.updateFanoutConfig(cfg.id, { enabled: !cfg.enabled });
|
|
await loadConfigs();
|
|
if (onHealthRefresh) await onHealthRefresh();
|
|
toast.success(cfg.enabled ? 'Integration disabled' : 'Integration enabled');
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to update');
|
|
}
|
|
};
|
|
|
|
const handleEdit = (cfg: FanoutConfig) => {
|
|
setCreateDialogOpen(false);
|
|
setInlineEditingId(null);
|
|
setInlineEditName('');
|
|
setDraftType(null);
|
|
setEditingId(cfg.id);
|
|
setEditConfig(cfg.config);
|
|
setEditScope(cfg.scope);
|
|
setEditName(cfg.name);
|
|
};
|
|
|
|
const handleStartInlineEdit = (cfg: FanoutConfig) => {
|
|
setCreateDialogOpen(false);
|
|
setInlineEditingId(cfg.id);
|
|
setInlineEditName(cfg.name);
|
|
};
|
|
|
|
const handleCancelInlineEdit = () => {
|
|
setInlineEditingId(null);
|
|
setInlineEditName('');
|
|
};
|
|
|
|
const handleBackToList = () => {
|
|
const shouldConfirm =
|
|
draftType !== null ||
|
|
fanoutDraftHasUnsavedChanges(
|
|
editingId ? (configs.find((c) => c.id === editingId) ?? null) : null,
|
|
{
|
|
name: editName,
|
|
config: editConfig,
|
|
scope: editScope,
|
|
}
|
|
);
|
|
if (shouldConfirm && !confirm('Leave without saving?')) return;
|
|
setEditingId(null);
|
|
setDraftType(null);
|
|
};
|
|
|
|
const handleInlineNameSave = async (cfg: FanoutConfig) => {
|
|
const nextName = inlineEditName.trim();
|
|
if (inlineEditingId !== cfg.id) return;
|
|
if (!nextName) {
|
|
toast.error('Name cannot be empty');
|
|
handleCancelInlineEdit();
|
|
return;
|
|
}
|
|
if (nextName === cfg.name) {
|
|
handleCancelInlineEdit();
|
|
return;
|
|
}
|
|
try {
|
|
await api.updateFanoutConfig(cfg.id, { name: nextName });
|
|
if (editingId === cfg.id) {
|
|
setEditName(nextName);
|
|
}
|
|
await loadConfigs();
|
|
toast.success('Name updated');
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to update name');
|
|
} finally {
|
|
handleCancelInlineEdit();
|
|
}
|
|
};
|
|
|
|
const handleSave = async (enabled?: boolean) => {
|
|
const currentDraftType = draftType;
|
|
const currentEditingId = editingId;
|
|
if (!currentEditingId && !currentDraftType) return;
|
|
setBusy(true);
|
|
try {
|
|
if (currentDraftType) {
|
|
const recipe = getCreateIntegrationDefinition(currentDraftType);
|
|
await api.createFanoutConfig({
|
|
type: recipe.savedType,
|
|
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
|
|
config: normalizeDraftConfig(currentDraftType, editConfig),
|
|
scope: normalizeDraftScope(currentDraftType, editScope),
|
|
enabled: enabled ?? true,
|
|
});
|
|
} else {
|
|
if (!currentEditingId) {
|
|
throw new Error('Missing fanout config id for update');
|
|
}
|
|
const editingType = configs.find((cfg) => cfg.id === currentEditingId)?.type ?? '';
|
|
const update: Record<string, unknown> = {
|
|
name: editName,
|
|
config: normalizeIntegrationConfigForSave(editingType, editConfig),
|
|
scope: editScope,
|
|
};
|
|
if (enabled !== undefined) update.enabled = enabled;
|
|
await api.updateFanoutConfig(currentEditingId, update);
|
|
}
|
|
setDraftType(null);
|
|
setEditingId(null);
|
|
await loadConfigs();
|
|
if (onHealthRefresh) {
|
|
try {
|
|
await onHealthRefresh();
|
|
} catch (err) {
|
|
console.error('Failed to refresh health after saving fanout config:', err);
|
|
}
|
|
}
|
|
toast.success(enabled ? 'Integration saved and enabled' : 'Integration saved');
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to save');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
const cfg = configs.find((c) => c.id === id);
|
|
if (!confirm(`Delete "${cfg?.name}"? This cannot be undone.`)) return;
|
|
try {
|
|
await api.deleteFanoutConfig(id);
|
|
if (editingId === id) setEditingId(null);
|
|
await loadConfigs();
|
|
if (onHealthRefresh) await onHealthRefresh();
|
|
toast.success('Integration deleted');
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to delete');
|
|
}
|
|
};
|
|
|
|
const handleAddCreate = (type: DraftType) => {
|
|
const definition = getCreateIntegrationDefinition(type);
|
|
const defaults = cloneDraftDefaults(type);
|
|
setCreateDialogOpen(false);
|
|
setEditingId(null);
|
|
setDraftType(type);
|
|
setEditName(
|
|
definition.nameMode === 'fixed'
|
|
? definition.defaultName
|
|
: getDefaultIntegrationName(definition.savedType, configs)
|
|
);
|
|
setEditConfig(defaults.config);
|
|
setEditScope(defaults.scope);
|
|
};
|
|
|
|
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
|
|
const detailType = draftType ?? editingConfig?.type ?? null;
|
|
const isDraft = draftType !== null;
|
|
const configGroups = Object.entries(TYPE_LABELS)
|
|
.map(([type, label]) => ({
|
|
type,
|
|
label,
|
|
configs: configs
|
|
.filter((cfg) => cfg.type === type)
|
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
|
}))
|
|
.filter((group) => group.configs.length > 0);
|
|
|
|
// Detail view
|
|
if (detailType) {
|
|
return (
|
|
<div className={cn('mx-auto w-full max-w-[800px] space-y-4', className)}>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-sm text-warning transition-colors hover:bg-warning/20"
|
|
onClick={handleBackToList}
|
|
>
|
|
← Back to list
|
|
</button>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fanout-edit-name">Name</Label>
|
|
<Input
|
|
id="fanout-edit-name"
|
|
type="text"
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="text-xs text-muted-foreground">Type: {getDetailTypeLabel(detailType)}</div>
|
|
|
|
<Separator />
|
|
|
|
{detailType === 'mqtt_private' && (
|
|
<MqttPrivateConfigEditor
|
|
config={editConfig}
|
|
scope={editScope}
|
|
onChange={setEditConfig}
|
|
onScopeChange={setEditScope}
|
|
/>
|
|
)}
|
|
|
|
{detailType === 'mqtt_ha' && (
|
|
<MqttHaConfigEditor
|
|
config={editConfig}
|
|
scope={editScope}
|
|
onChange={setEditConfig}
|
|
onScopeChange={setEditScope}
|
|
/>
|
|
)}
|
|
|
|
{detailType === 'mqtt_community' && (
|
|
<MqttCommunityConfigEditor config={editConfig} onChange={setEditConfig} />
|
|
)}
|
|
|
|
{detailType === 'mqtt_community_meshrank' && (
|
|
<MeshRankConfigEditor config={editConfig} onChange={setEditConfig} />
|
|
)}
|
|
|
|
{detailType === 'mqtt_community_letsmesh_us' && (
|
|
<LetsMeshConfigEditor
|
|
config={editConfig}
|
|
onChange={setEditConfig}
|
|
brokerHost={DEFAULT_COMMUNITY_BROKER_HOST}
|
|
/>
|
|
)}
|
|
|
|
{detailType === 'mqtt_community_letsmesh_eu' && (
|
|
<LetsMeshConfigEditor
|
|
config={editConfig}
|
|
onChange={setEditConfig}
|
|
brokerHost={DEFAULT_COMMUNITY_BROKER_HOST_EU}
|
|
/>
|
|
)}
|
|
|
|
{detailType === 'bot' && <BotConfigEditor config={editConfig} onChange={setEditConfig} />}
|
|
|
|
{detailType === 'apprise' && (
|
|
<AppriseConfigEditor
|
|
config={editConfig}
|
|
scope={editScope}
|
|
onChange={setEditConfig}
|
|
onScopeChange={setEditScope}
|
|
/>
|
|
)}
|
|
|
|
{detailType === 'webhook' && (
|
|
<WebhookConfigEditor
|
|
config={editConfig}
|
|
scope={editScope}
|
|
onChange={setEditConfig}
|
|
onScopeChange={setEditScope}
|
|
/>
|
|
)}
|
|
|
|
{detailType === 'sqs' && (
|
|
<SqsConfigEditor
|
|
config={editConfig}
|
|
scope={editScope}
|
|
onChange={setEditConfig}
|
|
onScopeChange={setEditScope}
|
|
/>
|
|
)}
|
|
|
|
{detailType === 'map_upload' && (
|
|
<MapUploadConfigEditor config={editConfig} onChange={setEditConfig} />
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={() => handleSave(true)}
|
|
disabled={busy}
|
|
className="flex-1 bg-status-connected hover:bg-status-connected/90 text-primary-foreground"
|
|
>
|
|
{busy ? 'Saving...' : 'Save as Enabled'}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => handleSave(false)}
|
|
disabled={busy}
|
|
className="flex-1"
|
|
>
|
|
{busy ? 'Saving...' : 'Save as Disabled'}
|
|
</Button>
|
|
{!isDraft && editingConfig && (
|
|
<Button variant="destructive" onClick={() => handleDelete(editingConfig.id)}>
|
|
Delete
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// List view
|
|
return (
|
|
<div className={cn('mx-auto w-full max-w-[800px] space-y-4', className)}>
|
|
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
|
|
Integrations are an experimental feature in open beta, and allow you to fanout raw and
|
|
decrypted messages across multiple services for automation, analysis, or archiving.
|
|
</div>
|
|
|
|
{health?.bots_disabled && (
|
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
{health.bots_disabled_source === 'until_restart'
|
|
? 'Bot system is disabled until the server restarts. Bot integrations cannot run, be created, or be modified right now.'
|
|
: 'Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations cannot run, be created, or be modified.'}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
|
|
Add Integration
|
|
</Button>
|
|
</div>
|
|
|
|
<CreateIntegrationDialog
|
|
open={createDialogOpen}
|
|
options={availableCreateOptions}
|
|
selectedType={selectedCreateType}
|
|
onOpenChange={setCreateDialogOpen}
|
|
onSelect={setSelectedCreateType}
|
|
onCreate={() => {
|
|
if (selectedCreateType) {
|
|
handleAddCreate(selectedCreateType);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<Dialog
|
|
open={errorDialogState !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setErrorDialogState(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader className="border-b border-border px-5 py-4">
|
|
<DialogTitle>
|
|
{errorDialogState ? `${errorDialogState.integrationName} Error` : 'Integration Error'}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Most recent backend error retained for this integration.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="px-5 py-4 text-sm text-muted-foreground">
|
|
<p className="whitespace-pre-wrap break-words font-mono text-foreground">
|
|
{errorDialogState?.error}
|
|
</p>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{configGroups.length > 0 && (
|
|
<div className="columns-1 gap-4 md:columns-2">
|
|
{configGroups.map((group) => (
|
|
<section
|
|
key={group.type}
|
|
className="mb-4 inline-block w-full break-inside-avoid space-y-2"
|
|
aria-label={`${group.label} integrations`}
|
|
>
|
|
<div className="px-1 text-sm font-medium text-muted-foreground">{group.label}</div>
|
|
<div className="space-y-2">
|
|
{group.configs.map((cfg) => {
|
|
const statusEntry = health?.fanout_statuses?.[cfg.id];
|
|
const status = cfg.enabled ? statusEntry?.status : undefined;
|
|
const lastError = cfg.enabled ? statusEntry?.last_error : null;
|
|
const communityConfig = cfg.config as Record<string, unknown>;
|
|
return (
|
|
<div
|
|
key={cfg.id}
|
|
role="group"
|
|
aria-label={`Integration ${cfg.name}`}
|
|
className="border border-input rounded-md overflow-hidden"
|
|
>
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50">
|
|
<label
|
|
className="flex items-center cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={cfg.enabled}
|
|
onChange={() => handleToggleEnabled(cfg)}
|
|
className="w-4 h-4 rounded border-input accent-primary"
|
|
aria-label={`Enable ${cfg.name}`}
|
|
/>
|
|
</label>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
{inlineEditingId === cfg.id ? (
|
|
<Input
|
|
value={inlineEditName}
|
|
autoFocus
|
|
onChange={(e) => setInlineEditName(e.target.value)}
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
onBlur={() => void handleInlineNameSave(cfg)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.currentTarget.blur();
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
handleCancelInlineEdit();
|
|
}
|
|
}}
|
|
aria-label={`Edit name for ${cfg.name}`}
|
|
className="h-8"
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="block max-w-full cursor-text truncate text-left text-sm font-medium hover:text-foreground/80"
|
|
onClick={() => handleStartInlineEdit(cfg)}
|
|
>
|
|
{cfg.name}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
'w-2 h-2 rounded-full transition-colors',
|
|
getStatusColor(status, cfg.enabled)
|
|
)}
|
|
title={cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
|
|
aria-hidden="true"
|
|
/>
|
|
<span className="text-xs text-muted-foreground hidden sm:inline">
|
|
{cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
|
|
</span>
|
|
|
|
{lastError && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 px-0"
|
|
onClick={() =>
|
|
setErrorDialogState({
|
|
integrationName: cfg.name,
|
|
error: lastError,
|
|
})
|
|
}
|
|
aria-label={`View error details for ${cfg.name}`}
|
|
title="View latest error"
|
|
>
|
|
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
onClick={() => handleEdit(cfg)}
|
|
>
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
|
|
{cfg.type === 'mqtt_community' && (
|
|
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
|
|
<div>
|
|
Broker:{' '}
|
|
{formatBrokerSummary(communityConfig, {
|
|
host: DEFAULT_COMMUNITY_BROKER_HOST,
|
|
port: DEFAULT_COMMUNITY_BROKER_PORT,
|
|
})}
|
|
</div>
|
|
<div className="break-all">
|
|
Topic:{' '}
|
|
<code>
|
|
{(communityConfig.topic_template as string) ||
|
|
DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{cfg.type === 'mqtt_private' && (
|
|
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
|
|
<div>
|
|
Broker:{' '}
|
|
{formatBrokerSummary(cfg.config as Record<string, unknown>, {
|
|
host: '',
|
|
port: 1883,
|
|
})}
|
|
</div>
|
|
<div className="break-all">
|
|
Topics:{' '}
|
|
<code>
|
|
{formatPrivateTopicSummary(cfg.config as Record<string, unknown>)}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{cfg.type === 'webhook' && (
|
|
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
|
|
<div className="break-all">
|
|
URL:{' '}
|
|
<code>
|
|
{((cfg.config as Record<string, unknown>).url as string) || 'Not set'}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{cfg.type === 'apprise' && (
|
|
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
|
|
<div className="break-all">
|
|
Targets:{' '}
|
|
<code>
|
|
{formatAppriseTargets(
|
|
(cfg.config as Record<string, unknown>).urls as string | undefined
|
|
)}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{cfg.type === 'sqs' && (
|
|
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
|
|
<div className="break-all">
|
|
Queue:{' '}
|
|
<code>
|
|
{formatSqsQueueSummary(cfg.config as Record<string, unknown>)}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|