Move to modular fanout bus

This commit is contained in:
Jack Kingsman
2026-03-05 17:16:13 -08:00
parent 93b5bd908a
commit 7cd54d14d8
34 changed files with 2489 additions and 1292 deletions
+32
View File
@@ -8,6 +8,7 @@ import type {
ContactAdvertPath,
ContactAdvertPathSummary,
ContactDetail,
FanoutConfig,
Favorite,
HealthStatus,
MaintenanceResult,
@@ -280,6 +281,37 @@ export const api = {
body: JSON.stringify(request),
}),
// Fanout
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
createFanoutConfig: (config: {
type: string;
name: string;
config: Record<string, unknown>;
scope: Record<string, unknown>;
enabled?: boolean;
}) =>
fetchJson<FanoutConfig>('/fanout', {
method: 'POST',
body: JSON.stringify(config),
}),
updateFanoutConfig: (
id: string,
update: {
name?: string;
config?: Record<string, unknown>;
scope?: Record<string, unknown>;
enabled?: boolean;
}
) =>
fetchJson<FanoutConfig>(`/fanout/${id}`, {
method: 'PATCH',
body: JSON.stringify(update),
}),
deleteFanoutConfig: (id: string) =>
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
method: 'DELETE',
}),
// Statistics
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
+6 -11
View File
@@ -11,7 +11,7 @@ import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settings/settin
import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsLocalSection } from './settings/SettingsLocalSection';
import { SettingsMqttSection } from './settings/SettingsMqttSection';
import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
import { SettingsBotSection } from './settings/SettingsBotSection';
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
@@ -78,7 +78,7 @@ export function SettingsModal(props: SettingsModalProps) {
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>({
radio: false,
local: false,
mqtt: false,
fanout: false,
database: false,
bot: false,
statistics: false,
@@ -232,16 +232,11 @@ export function SettingsModal(props: SettingsModalProps) {
</section>
)}
{shouldRenderSection('mqtt') && (
{shouldRenderSection('fanout') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('mqtt')}
{isSectionVisible('mqtt') && appSettings && (
<SettingsMqttSection
appSettings={appSettings}
health={health}
onSaveAppSettings={onSaveAppSettings}
className={sectionContentClass}
/>
{renderSectionHeader('fanout')}
{isSectionVisible('fanout') && (
<SettingsFanoutSection health={health} className={sectionContentClass} />
)}
</section>
)}
@@ -0,0 +1,505 @@
import { useState, useEffect, useCallback } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { cn } from '@/lib/utils';
import { api } from '../../api';
import type { FanoutConfig, HealthStatus } from '../../types';
const TYPE_LABELS: Record<string, string> = {
mqtt_private: 'Private MQTT',
mqtt_community: 'Community MQTT',
};
const TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community', label: 'Community MQTT' },
];
function getStatusColor(status: string | undefined) {
if (status === 'connected')
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
return 'bg-muted-foreground';
}
function getStatusLabel(status: string | undefined) {
if (status === 'connected') return 'Connected';
if (status === 'disconnected') return 'Disconnected';
return 'Inactive';
}
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-xs text-muted-foreground">
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
</p>
<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={(config.broker_port as number) || 1883}
onChange={(e) =>
onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 1883 })
}
/>
</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"
value={(config.topic_prefix as string) || 'meshcore'}
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
/>
</div>
<Separator />
<div className="space-y-2">
<Label>Scope</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={scope.messages === 'all'}
onChange={(e) =>
onScopeChange({ ...scope, messages: e.target.checked ? 'all' : 'none' })
}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Forward decoded messages</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={scope.raw_packets === 'all'}
onChange={(e) =>
onScopeChange({ ...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>
</div>
);
}
function MqttCommunityConfigEditor({
config,
onChange,
}: {
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Share raw packet data with the MeshCore community for coverage mapping and network analysis.
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="mqtt-us-v1.letsmesh.net"
value={(config.broker_host as string) || 'mqtt-us-v1.letsmesh.net'}
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={(config.broker_port as number) || 443}
onChange={(e) =>
onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 443 })
}
/>
</div>
</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-xs text-muted-foreground">
Your nearest airport&apos;s IATA code (required)
</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-xs text-muted-foreground">
Used to claim your node on the community aggregator
</p>
</div>
</div>
);
}
export function SettingsFanoutSection({
health,
className,
}: {
health: HealthStatus | null;
className?: string;
}) {
const [configs, setConfigs] = useState<FanoutConfig[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [editConfig, setEditConfig] = useState<Record<string, unknown>>({});
const [editScope, setEditScope] = useState<Record<string, unknown>>({});
const [editName, setEditName] = useState('');
const [busy, setBusy] = useState(false);
const [addingType, setAddingType] = useState<string | null>(null);
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 handleToggleEnabled = async (cfg: FanoutConfig) => {
try {
await api.updateFanoutConfig(cfg.id, { enabled: !cfg.enabled });
await loadConfigs();
toast.success(cfg.enabled ? 'Integration disabled' : 'Integration enabled');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
}
};
const handleEdit = (cfg: FanoutConfig) => {
setEditingId(cfg.id);
setEditConfig(cfg.config);
setEditScope(cfg.scope);
setEditName(cfg.name);
};
const handleSave = async () => {
if (!editingId) return;
setBusy(true);
try {
await api.updateFanoutConfig(editingId, {
name: editName,
config: editConfig,
scope: editScope,
});
await loadConfigs();
setEditingId(null);
toast.success('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();
toast.success('Integration deleted');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete');
}
};
const handleAddStart = (type: string) => {
setAddingType(type);
};
const handleAddCreate = async (type: string) => {
const defaults: Record<string, Record<string, unknown>> = {
mqtt_private: {
broker_host: '',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
mqtt_community: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
iata: '',
email: '',
},
};
const defaultScopes: Record<string, Record<string, unknown>> = {
mqtt_private: { messages: 'all', raw_packets: 'all' },
mqtt_community: { messages: 'none', raw_packets: 'all' },
};
try {
const created = await api.createFanoutConfig({
type,
name: TYPE_LABELS[type] || type,
config: defaults[type] || {},
scope: defaultScopes[type] || {},
enabled: false,
});
await loadConfigs();
setAddingType(null);
handleEdit(created);
toast.success('Integration created');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create');
}
};
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
// Detail view
if (editingConfig) {
return (
<div className={cn('space-y-4', className)}>
<button
type="button"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setEditingId(null)}
>
&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: {TYPE_LABELS[editingConfig.type] || editingConfig.type}
</div>
<Separator />
{editingConfig.type === 'mqtt_private' && (
<MqttPrivateConfigEditor
config={editConfig}
scope={editScope}
onChange={setEditConfig}
onScopeChange={setEditScope}
/>
)}
{editingConfig.type === 'mqtt_community' && (
<MqttCommunityConfigEditor config={editConfig} onChange={setEditConfig} />
)}
<Separator />
<div className="flex gap-2">
<Button onClick={handleSave} disabled={busy} className="flex-1">
{busy ? 'Saving...' : 'Save'}
</Button>
<Button variant="destructive" onClick={() => handleDelete(editingConfig.id)}>
Delete
</Button>
</div>
</div>
);
}
// List view
return (
<div className={cn('space-y-4', className)}>
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
MQTT support is an experimental feature in open beta. All publishing uses QoS 0
(at-most-once delivery).
</div>
{configs.length === 0 ? (
<div className="text-center py-8 border border-dashed border-input rounded-md">
<p className="text-muted-foreground mb-4">No integrations configured</p>
</div>
) : (
<div className="space-y-2">
{configs.map((cfg) => {
const statusEntry = health?.fanout_statuses?.[cfg.id];
const status = cfg.enabled ? statusEntry?.status : undefined;
return (
<div key={cfg.id} 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>
<span className="text-sm font-medium flex-1">{cfg.name}</span>
<span className="text-xs text-muted-foreground">
{TYPE_LABELS[cfg.type] || cfg.type}
</span>
<div
className={cn('w-2 h-2 rounded-full transition-colors', getStatusColor(status))}
title={getStatusLabel(status)}
aria-hidden="true"
/>
<span className="text-xs text-muted-foreground hidden sm:inline">
{cfg.enabled ? getStatusLabel(status) : 'Disabled'}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleEdit(cfg)}
>
Edit
</Button>
</div>
</div>
);
})}
</div>
)}
{addingType ? (
<div className="border border-input rounded-md p-3 space-y-2">
<Label>Select integration type:</Label>
<div className="flex flex-wrap gap-2">
{TYPE_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant={addingType === opt.value ? 'default' : 'outline'}
size="sm"
onClick={() => handleAddCreate(opt.value)}
>
{opt.label}
</Button>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => setAddingType(null)}>
Cancel
</Button>
</div>
) : (
<Button variant="outline" onClick={() => handleAddStart('mqtt_private')} className="w-full">
+ Add Integration
</Button>
)}
</div>
);
}
@@ -1,451 +0,0 @@
import { useState, useEffect } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { cn } from '@/lib/utils';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsMqttSection({
appSettings,
health,
onSaveAppSettings,
className,
}: {
appSettings: AppSettings;
health: HealthStatus | null;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
className?: string;
}) {
const [mqttBrokerHost, setMqttBrokerHost] = useState('');
const [mqttBrokerPort, setMqttBrokerPort] = useState('1883');
const [mqttUsername, setMqttUsername] = useState('');
const [mqttPassword, setMqttPassword] = useState('');
const [mqttUseTls, setMqttUseTls] = useState(false);
const [mqttTlsInsecure, setMqttTlsInsecure] = useState(false);
const [mqttTopicPrefix, setMqttTopicPrefix] = useState('meshcore');
const [mqttPublishMessages, setMqttPublishMessages] = useState(false);
const [mqttPublishRawPackets, setMqttPublishRawPackets] = useState(false);
// Community MQTT state
const [communityMqttEnabled, setCommunityMqttEnabled] = useState(false);
const [communityMqttIata, setCommunityMqttIata] = useState('');
const [communityMqttBrokerHost, setCommunityMqttBrokerHost] = useState('mqtt-us-v1.letsmesh.net');
const [communityMqttBrokerPort, setCommunityMqttBrokerPort] = useState('443');
const [communityMqttEmail, setCommunityMqttEmail] = useState('');
const [privateExpanded, setPrivateExpanded] = useState(false);
const [communityExpanded, setCommunityExpanded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setMqttBrokerHost(appSettings.mqtt_broker_host ?? '');
setMqttBrokerPort(String(appSettings.mqtt_broker_port ?? 1883));
setMqttUsername(appSettings.mqtt_username ?? '');
setMqttPassword(appSettings.mqtt_password ?? '');
setMqttUseTls(appSettings.mqtt_use_tls ?? false);
setMqttTlsInsecure(appSettings.mqtt_tls_insecure ?? false);
setMqttTopicPrefix(appSettings.mqtt_topic_prefix ?? 'meshcore');
setMqttPublishMessages(appSettings.mqtt_publish_messages ?? false);
setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false);
setCommunityMqttEnabled(appSettings.community_mqtt_enabled ?? false);
setCommunityMqttIata(appSettings.community_mqtt_iata ?? '');
setCommunityMqttBrokerHost(appSettings.community_mqtt_broker_host ?? 'mqtt-us-v1.letsmesh.net');
setCommunityMqttBrokerPort(String(appSettings.community_mqtt_broker_port ?? 443));
setCommunityMqttEmail(appSettings.community_mqtt_email ?? '');
}, [appSettings]);
const handleSave = async () => {
setError(null);
setBusy(true);
try {
const update: AppSettingsUpdate = {
mqtt_broker_host: mqttBrokerHost,
mqtt_broker_port: parseInt(mqttBrokerPort, 10) || 1883,
mqtt_username: mqttUsername,
mqtt_password: mqttPassword,
mqtt_use_tls: mqttUseTls,
mqtt_tls_insecure: mqttTlsInsecure,
mqtt_topic_prefix: mqttTopicPrefix || 'meshcore',
mqtt_publish_messages: mqttPublishMessages,
mqtt_publish_raw_packets: mqttPublishRawPackets,
community_mqtt_enabled: communityMqttEnabled,
community_mqtt_iata: communityMqttIata,
community_mqtt_broker_host: communityMqttBrokerHost || 'mqtt-us-v1.letsmesh.net',
community_mqtt_broker_port: parseInt(communityMqttBrokerPort, 10) || 443,
community_mqtt_email: communityMqttEmail,
};
await onSaveAppSettings(update);
toast.success('MQTT settings saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setBusy(false);
}
};
return (
<div className={className}>
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
MQTT support is an experimental feature in open beta. All publishing uses QoS 0
(at-most-once delivery). Please report any bugs on the{' '}
<a
href="https://github.com/jkingsman/Remote-Terminal-for-MeshCore/issues"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-warning-foreground"
>
GitHub issues page
</a>
.
</div>
<div className="rounded-md border border-info/50 bg-info/10 px-4 py-3 text-sm text-info">
Outgoing messages (DMs and group messages) will be reported to private MQTT brokers in
decrypted/plaintext form. The raw outgoing packets will NOT be reported to any MQTT broker,
private or community. This means that{' '}
<strong>
your advertisements will not be reported to community analytics (LetsMesh/etc.) due to
fundamental limitations of the radio
</strong>{' '}
&mdash; you don&apos;t hear your own advertisements unless they&apos;re echoed back to you.
So, your own advert echoes may result in you being listed on LetsMesh/etc., but if
you&apos;re alone in your mesh, your node will appear as an ingest source within LetsMesh,
without GPS data/etc. derived from adverts -- we faithfully report only traffic heard on the
radio (and don&apos;t reconstruct synthetic advertisement events to submit). Rely on the
&ldquo;My Nodes&rdquo; or view heard packets to validate that your radio is submitting to
community sources; if you&apos;re alone in your local mesh, the radio itself may not appear
as a heard/mapped source.
</div>
{/* Private MQTT Broker */}
<div className="border border-input rounded-md overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
aria-expanded={privateExpanded}
onClick={() => setPrivateExpanded(!privateExpanded)}
>
<span className="text-muted-foreground" aria-hidden="true">
{privateExpanded ? '▼' : '▶'}
</span>
<h4 className="text-sm font-medium">Private MQTT Broker</h4>
<div
className={cn(
'w-2 h-2 rounded-full transition-colors',
health?.mqtt_status === 'connected'
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
: 'bg-muted-foreground'
)}
/>
<span className="text-xs text-muted-foreground">
{health?.mqtt_status === 'connected'
? 'Connected'
: health?.mqtt_status === 'disconnected'
? 'Disconnected'
: 'Disabled'}
</span>
</button>
{privateExpanded && (
<div className="px-4 pb-4 space-y-3 border-t border-input">
<p className="text-xs text-muted-foreground pt-3">
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttPublishMessages}
onChange={(e) => setMqttPublishMessages(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Publish Messages</span>
</label>
<p className="text-xs text-muted-foreground ml-7">
Forward decrypted DM and channel messages
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttPublishRawPackets}
onChange={(e) => setMqttPublishRawPackets(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Publish Raw Packets</span>
</label>
<p className="text-xs text-muted-foreground ml-7">Forward all RF packets</p>
{(mqttPublishMessages || mqttPublishRawPackets) && (
<div className="space-y-3">
<Separator />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mqtt-host">Broker Host</Label>
<Input
id="mqtt-host"
type="text"
placeholder="e.g. 192.168.1.100"
value={mqttBrokerHost}
onChange={(e) => setMqttBrokerHost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-port">Broker Port</Label>
<Input
id="mqtt-port"
type="number"
min="1"
max="65535"
value={mqttBrokerPort}
onChange={(e) => setMqttBrokerPort(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mqtt-username">Username</Label>
<Input
id="mqtt-username"
type="text"
placeholder="Optional"
value={mqttUsername}
onChange={(e) => setMqttUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-password">Password</Label>
<Input
id="mqtt-password"
type="password"
placeholder="Optional"
value={mqttPassword}
onChange={(e) => setMqttPassword(e.target.value)}
/>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttUseTls}
onChange={(e) => setMqttUseTls(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Use TLS</span>
</label>
{mqttUseTls && (
<>
<label className="flex items-center gap-3 cursor-pointer ml-7">
<input
type="checkbox"
checked={mqttTlsInsecure}
onChange={(e) => setMqttTlsInsecure(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Skip certificate verification</span>
</label>
<p className="text-xs text-muted-foreground ml-7">
Allow self-signed or untrusted broker certificates
</p>
</>
)}
<Separator />
<div className="space-y-2">
<Label htmlFor="mqtt-prefix">Topic Prefix</Label>
<Input
id="mqtt-prefix"
type="text"
value={mqttTopicPrefix}
onChange={(e) => setMqttTopicPrefix(e.target.value)}
/>
<div className="text-xs text-muted-foreground space-y-2">
<div>
<p className="font-medium">
Decrypted messages{' '}
<span className="font-mono font-normal opacity-75">
{'{'}id, type, conversation_key, text, sender_timestamp, received_at,
paths, outgoing, acked{'}'}
</span>
</p>
<div className="font-mono ml-2 space-y-0.5">
<div>{mqttTopicPrefix || 'meshcore'}/dm:&lt;contact_key&gt;</div>
<div>{mqttTopicPrefix || 'meshcore'}/gm:&lt;channel_key&gt;</div>
</div>
</div>
<div>
<p className="font-medium">
Raw packets{' '}
<span className="font-mono font-normal opacity-75">
{'{'}id, observation_id, timestamp, data, payload_type, snr, rssi,
decrypted, decrypted_info{'}'}
</span>
</p>
<div className="font-mono ml-2 space-y-0.5">
<div>{mqttTopicPrefix || 'meshcore'}/raw/dm:&lt;contact_key&gt;</div>
<div>{mqttTopicPrefix || 'meshcore'}/raw/gm:&lt;channel_key&gt;</div>
<div>{mqttTopicPrefix || 'meshcore'}/raw/unrouted</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Community Analytics */}
<div className="border border-input rounded-md overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
aria-expanded={communityExpanded}
onClick={() => setCommunityExpanded(!communityExpanded)}
>
<span className="text-muted-foreground" aria-hidden="true">
{communityExpanded ? '▼' : '▶'}
</span>
<h4 className="text-sm font-medium">Community Analytics</h4>
<div
className={cn(
'w-2 h-2 rounded-full transition-colors',
health?.community_mqtt_status === 'connected'
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
: 'bg-muted-foreground'
)}
/>
<span className="text-xs text-muted-foreground">
{health?.community_mqtt_status === 'connected'
? 'Connected'
: health?.community_mqtt_status === 'disconnected'
? 'Disconnected'
: 'Disabled'}
</span>
</button>
{communityExpanded && (
<div className="px-4 pb-4 space-y-3 border-t border-input">
<p className="text-xs text-muted-foreground pt-3">
Share raw packet data with the MeshCore community for coverage mapping and network
analysis. Only raw RF packets are shared never decrypted messages. General parity
with{' '}
<a
href="https://github.com/agessaman/meshcore-packet-capture"
target="_blank"
rel="noopener noreferrer"
>
meshcore-packet-capture
</a>
.
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={communityMqttEnabled}
onChange={(e) => setCommunityMqttEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Enable Community Analytics</span>
</label>
{communityMqttEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="community-broker-host">Broker Host</Label>
<Input
id="community-broker-host"
type="text"
placeholder="mqtt-us-v1.letsmesh.net"
value={communityMqttBrokerHost}
onChange={(e) => setCommunityMqttBrokerHost(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
MQTT over TLS (WebSocket Secure) only
</p>
</div>
<div className="space-y-2">
<Label htmlFor="community-broker-port">Broker Port</Label>
<Input
id="community-broker-port"
type="number"
min="1"
max="65535"
value={communityMqttBrokerPort}
onChange={(e) => setCommunityMqttBrokerPort(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="community-iata">Region Code (IATA)</Label>
<Input
id="community-iata"
type="text"
maxLength={3}
placeholder="e.g. DEN, LAX, NYC"
value={communityMqttIata}
onChange={(e) => setCommunityMqttIata(e.target.value.toUpperCase())}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
Your nearest airport&apos;s{' '}
<a
href="https://en.wikipedia.org/wiki/List_of_airports_by_IATA_airport_code:_A"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
IATA code
</a>{' '}
(required)
</p>
{communityMqttIata && (
<p className="text-xs text-muted-foreground">
Topic: meshcore/{communityMqttIata}/&lt;pubkey&gt;/packets
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="community-email">Owner Email (optional)</Label>
<Input
id="community-email"
type="email"
placeholder="you@example.com"
value={communityMqttEmail}
onChange={(e) => setCommunityMqttEmail(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Used to claim your node on the community aggregator
</p>
</div>
</div>
)}
</div>
)}
</div>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save MQTT Settings'}
</Button>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
</div>
);
}
@@ -3,7 +3,7 @@ export type SettingsSection =
| 'local'
| 'database'
| 'bot'
| 'mqtt'
| 'fanout'
| 'statistics'
| 'about';
@@ -12,7 +12,7 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'local',
'database',
'bot',
'mqtt',
'fanout',
'statistics',
'about',
];
@@ -22,7 +22,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
bot: '🤖 Bots',
mqtt: '📤 MQTT',
fanout: '📤 Fanout & Forwarding',
statistics: '📊 Statistics',
about: 'About',
};
+1 -157
View File
@@ -38,8 +38,7 @@ const baseHealth: HealthStatus = {
connection_info: 'Serial: /dev/ttyUSB0',
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
mqtt_status: null,
community_mqtt_status: null,
fanout_statuses: {},
bots_disabled: false,
};
@@ -159,19 +158,6 @@ function openLocalSection() {
fireEvent.click(localToggle);
}
function openMqttSection() {
const mqttToggle = screen.getByRole('button', { name: /MQTT/i });
fireEvent.click(mqttToggle);
}
function expandPrivateMqtt() {
fireEvent.click(screen.getByText('Private MQTT Broker'));
}
function expandCommunityMqtt() {
fireEvent.click(screen.getByText('Community Analytics'));
}
function openDatabaseSection() {
const databaseToggle = screen.getByRole('button', { name: /Database/i });
fireEvent.click(databaseToggle);
@@ -430,148 +416,6 @@ describe('SettingsModal', () => {
expect(screen.getByText('42 msgs')).toBeInTheDocument();
});
it('renders MQTT section with form inputs', () => {
renderModal();
openMqttSection();
expandPrivateMqtt();
// Publish checkboxes always visible
expect(screen.getByText('Publish Messages')).toBeInTheDocument();
expect(screen.getByText('Publish Raw Packets')).toBeInTheDocument();
// Broker config hidden until a publish option is enabled
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
// Enable one publish option to reveal broker config
fireEvent.click(screen.getByText('Publish Messages'));
expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
expect(screen.getByLabelText('Broker Port')).toBeInTheDocument();
expect(screen.getByLabelText('Username')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByLabelText('Topic Prefix')).toBeInTheDocument();
});
it('saves MQTT settings through onSaveAppSettings', async () => {
const { onSaveAppSettings } = renderModal({
appSettings: { ...baseSettings, mqtt_publish_messages: true },
});
openMqttSection();
expandPrivateMqtt();
const hostInput = screen.getByLabelText('Broker Host');
fireEvent.change(hostInput, { target: { value: 'mqtt.example.com' } });
fireEvent.click(screen.getByRole('button', { name: 'Save MQTT Settings' }));
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith(
expect.objectContaining({
mqtt_broker_host: 'mqtt.example.com',
mqtt_broker_port: 1883,
})
);
});
});
it('shows MQTT disabled status when mqtt_status is null', () => {
renderModal({
appSettings: {
...baseSettings,
mqtt_broker_host: 'broker.local',
},
});
openMqttSection();
// Both MQTT and community MQTT show "Disabled" when null status
const disabledElements = screen.getAllByText('Disabled');
expect(disabledElements.length).toBeGreaterThanOrEqual(1);
});
it('shows MQTT connected status badge', () => {
renderModal({
appSettings: {
...baseSettings,
mqtt_broker_host: 'broker.local',
},
health: {
...baseHealth,
mqtt_status: 'connected',
},
});
openMqttSection();
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('renders community sharing section in MQTT tab', () => {
renderModal();
openMqttSection();
expandCommunityMqtt();
expect(screen.getByText('Community Analytics')).toBeInTheDocument();
expect(screen.getByText('Enable Community Analytics')).toBeInTheDocument();
});
it('shows IATA input only when community sharing is enabled', () => {
renderModal({
appSettings: {
...baseSettings,
community_mqtt_enabled: false,
},
});
openMqttSection();
expandCommunityMqtt();
expect(screen.queryByLabelText('Region Code (IATA)')).not.toBeInTheDocument();
// Enable community sharing
fireEvent.click(screen.getByText('Enable Community Analytics'));
expect(screen.getByLabelText('Region Code (IATA)')).toBeInTheDocument();
});
it('includes community MQTT fields in save payload', async () => {
const { onSaveAppSettings } = renderModal({
appSettings: {
...baseSettings,
community_mqtt_enabled: true,
community_mqtt_iata: 'DEN',
},
});
openMqttSection();
fireEvent.click(screen.getByRole('button', { name: 'Save MQTT Settings' }));
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith(
expect.objectContaining({
community_mqtt_enabled: true,
community_mqtt_iata: 'DEN',
})
);
});
});
it('shows community MQTT connected status badge', () => {
renderModal({
appSettings: {
...baseSettings,
community_mqtt_enabled: true,
},
health: {
...baseHealth,
community_mqtt_status: 'connected',
},
});
openMqttSection();
// Community Analytics sub-section should show Connected
const communitySection = screen.getByText('Community Analytics').closest('div');
expect(communitySection).not.toBeNull();
// Both MQTT and community could show "Connected" — check count
const connectedElements = screen.getAllByText('Connected');
expect(connectedElements.length).toBeGreaterThanOrEqual(1);
});
it('fetches statistics when expanded in mobile external-nav mode', async () => {
const mockStats: StatisticsResponse = {
busiest_channels_24h: [],
+18 -16
View File
@@ -23,17 +23,33 @@ export interface RadioConfigUpdate {
radio?: RadioSettings;
}
export interface FanoutStatusEntry {
name: string;
type: string;
status: string;
}
export interface HealthStatus {
status: string;
radio_connected: boolean;
connection_info: string | null;
database_size_mb: number;
oldest_undecrypted_timestamp: number | null;
mqtt_status: string | null;
community_mqtt_status: string | null;
fanout_statuses: Record<string, FanoutStatusEntry>;
bots_disabled: boolean;
}
export interface FanoutConfig {
id: string;
type: string;
name: string;
enabled: boolean;
config: Record<string, unknown>;
scope: Record<string, unknown>;
sort_order: number;
created_at: number;
}
export interface MaintenanceResult {
packets_deleted: number;
vacuumed: boolean;
@@ -240,20 +256,6 @@ export interface AppSettingsUpdate {
sidebar_sort_order?: 'recent' | 'alpha';
advert_interval?: number;
bots?: BotConfig[];
mqtt_broker_host?: string;
mqtt_broker_port?: number;
mqtt_username?: string;
mqtt_password?: string;
mqtt_use_tls?: boolean;
mqtt_tls_insecure?: boolean;
mqtt_topic_prefix?: string;
mqtt_publish_messages?: boolean;
mqtt_publish_raw_packets?: boolean;
community_mqtt_enabled?: boolean;
community_mqtt_iata?: string;
community_mqtt_broker_host?: string;
community_mqtt_broker_port?: number;
community_mqtt_email?: string;
flood_scope?: string;
blocked_keys?: string[];
blocked_names?: string[];