Files
Remote-Terminal-for-MeshCore/frontend/src/components/settings/SettingsFanoutSection.tsx
T
2026-06-11 20:42:29 -07:00

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&apos;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">&mdash; 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>{' '}
&mdash; radio online/offline
</li>
<li>
<code className="text-[0.6875rem]">
{`sensor.meshcore_${localRadioNodeId}_noise_floor`}
</code>{' '}
&mdash; radio noise floor (dBm)
</li>
</ul>
</div>
<div>
<span className="font-medium text-foreground">Per tracked repeater</span> &mdash;
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. &mdash; CayenneLPP sensors (auto-detected from repeater)
</li>
</ul>
</div>
<div>
<span className="font-medium text-foreground">Per tracked contact</span> &mdash; 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>{' '}
&mdash; latitude/longitude
</li>
</ul>
</div>
<div>
<span className="font-medium text-foreground">Message events</span> &mdash; 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>{' '}
&mdash; 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&apos;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}/&lt;node_id&gt;/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&apos;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)}`}
>
&times;
</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 &ldquo;{contactSearch}&rdquo;
</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 &mdash; 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&apos;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&apos;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&apos;s private key to be available (firmware must have{' '}
<code>ENABLE_PRIVATE_KEY_EXPORT=1</code>). Only raw RF packets are shared &mdash; 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&apos;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 &rarr; Radio &rarr; 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&apos;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">
&gt;{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}`}
>
&times;
</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 &ldquo;{search}&rdquo;
</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 &mdash; 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 &quot;direct&quot;
</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&apos;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}
>
&larr; 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>
);
}