mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 16:53:38 +02:00
Move to modular fanout bus
This commit is contained in:
@@ -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'),
|
||||
|
||||
|
||||
@@ -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 — 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'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)}
|
||||
>
|
||||
← 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>{' '}
|
||||
— you don't hear your own advertisements unless they're echoed back to you.
|
||||
So, your own advert echoes may result in you being listed on LetsMesh/etc., but if
|
||||
you'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't reconstruct synthetic advertisement events to submit). Rely on the
|
||||
“My Nodes” or view heard packets to validate that your radio is submitting to
|
||||
community sources; if you'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:<contact_key></div>
|
||||
<div>{mqttTopicPrefix || 'meshcore'}/gm:<channel_key></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:<contact_key></div>
|
||||
<div>{mqttTopicPrefix || 'meshcore'}/raw/gm:<channel_key></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'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}/<pubkey>/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',
|
||||
};
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user