diff --git a/app/fanout/AGENTS_fanout.md b/app/fanout/AGENTS_fanout.md
index 4417864..b816f1c 100644
--- a/app/fanout/AGENTS_fanout.md
+++ b/app/fanout/AGENTS_fanout.md
@@ -190,7 +190,11 @@ function MyTypeConfigEditor({
}
```
-If your type does NOT have user-configurable scope (like bot or community MQTT), omit the `scope`/`onScopeChange` props and the `ScopeSelector`. The `ScopeSelector` component is defined within the same file — it provides all/none/only/except radio buttons with channel and contact checklists.
+If your type does NOT have user-configurable scope (like bot or community MQTT), omit the `scope`/`onScopeChange` props and the `ScopeSelector`.
+
+The `ScopeSelector` component is defined within the same file. It accepts an optional `showRawPackets` prop:
+- **Without `showRawPackets`** (webhook, apprise): shows message scope only (all/only/except — no "none" option since that would make the integration a no-op). A warning appears when the effective selection matches nothing.
+- **With `showRawPackets`** (private MQTT): adds a "Forward raw packets" toggle and includes the "No messages" option (valid when raw packets are enabled). The warning appears only when both raw packets and messages are effectively disabled.
**c)** Add default config and scope in `handleAddCreate`:
```tsx
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index baf1ecc..7809c2a 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -189,31 +189,7 @@ function MqttPrivateConfigEditor({
-
- Scope
-
-
- onScopeChange({ ...scope, messages: e.target.checked ? 'all' : 'none' })
- }
- className="h-4 w-4 rounded border-border"
- />
- Forward decoded messages
-
-
-
- onScopeChange({ ...scope, raw_packets: e.target.checked ? 'all' : 'none' })
- }
- className="h-4 w-4 rounded border-border"
- />
- Forward raw packets
-
-
+
);
}
@@ -398,9 +374,11 @@ function getFilterKeys(filter: unknown): string[] {
function ScopeSelector({
scope,
onChange,
+ showRawPackets = false,
}: {
scope: Record;
onChange: (scope: Record) => void;
+ showRawPackets?: boolean;
}) {
const [channels, setChannels] = useState([]);
const [contacts, setContacts] = useState([]);
@@ -425,7 +403,9 @@ function ScopeSelector({
}, []);
const messages = scope.messages ?? 'all';
- const mode = getScopeMode(messages);
+ 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[] =
@@ -487,6 +467,19 @@ function ScopeSelector({
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;
+
// For "except" mode, checked means the item is in the exclusion list (will be excluded)
const isChannelChecked = (key: string) =>
mode === 'except' ? selectedChannels.includes(key) : selectedChannels.includes(key);
@@ -500,11 +493,28 @@ function ScopeSelector({
const checkboxLabel = mode === 'except' ? 'exclude' : 'include';
+ const messageModes: ScopeMode[] = showRawPackets
+ ? ['all', 'none', 'only', 'except']
+ : ['all', 'only', 'except'];
+
return (
Message Scope
+
+ {showRawPackets && (
+
+ onChange({ ...scope, raw_packets: e.target.checked ? 'all' : 'none' })}
+ className="h-4 w-4 rounded border-border"
+ />
+ Forward raw packets
+
+ )}
+
- {(['all', 'none', 'only', 'except'] as const).map((m) => (
+ {messageModes.map((m) => (
+ {showEmptyScopeWarning && (
+
+ Nothing is selected — this integration will not forward any data.
+
+ )}
+
{isListMode && (
<>
{listHint}
diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx
index c93c88c..d5b79e9 100644
--- a/frontend/src/test/fanoutSection.test.tsx
+++ b/frontend/src/test/fanoutSection.test.tsx
@@ -125,6 +125,118 @@ describe('SettingsFanoutSection', () => {
});
});
+ it('webhook with persisted "none" scope renders "All messages" selected', async () => {
+ const wh: FanoutConfig = {
+ ...webhookConfig,
+ scope: { messages: 'none', raw_packets: 'none' },
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([wh]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ // "none" is not a valid mode without raw packets — should fall back to "all"
+ const allRadio = screen.getByLabelText('All messages');
+ expect(allRadio).toBeChecked();
+ });
+
+ it('does not show "No messages" scope option for webhook', async () => {
+ const wh: FanoutConfig = {
+ ...webhookConfig,
+ scope: { messages: 'all', raw_packets: 'none' },
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([wh]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.getByText('All messages')).toBeInTheDocument();
+ expect(screen.queryByText('No messages')).not.toBeInTheDocument();
+ });
+
+ it('shows empty scope warning when "only" mode has nothing selected', async () => {
+ const wh: FanoutConfig = {
+ ...webhookConfig,
+ scope: { messages: { channels: [], contacts: [] }, raw_packets: 'none' },
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([wh]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.getByText(/will not forward any data/)).toBeInTheDocument();
+ });
+
+ it('shows warning for private MQTT when both scope axes are off', async () => {
+ const mqtt: FanoutConfig = {
+ id: 'mqtt-1',
+ type: 'mqtt_private',
+ name: 'My MQTT',
+ enabled: true,
+ config: { broker_host: 'localhost', broker_port: 1883 },
+ scope: { messages: 'none', raw_packets: 'none' },
+ sort_order: 0,
+ created_at: 1000,
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([mqtt]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('My MQTT')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.getByText(/will not forward any data/)).toBeInTheDocument();
+ });
+
+ it('private MQTT shows raw packets toggle and No messages option', async () => {
+ const mqtt: FanoutConfig = {
+ id: 'mqtt-1',
+ type: 'mqtt_private',
+ name: 'My MQTT',
+ enabled: true,
+ config: { broker_host: 'localhost', broker_port: 1883 },
+ scope: { messages: 'all', raw_packets: 'all' },
+ sort_order: 0,
+ created_at: 1000,
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([mqtt]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('My MQTT')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.getByText('Forward raw packets')).toBeInTheDocument();
+ expect(screen.getByText('No messages')).toBeInTheDocument();
+ });
+
+ it('private MQTT hides warning when raw packets enabled but messages off', async () => {
+ const mqtt: FanoutConfig = {
+ id: 'mqtt-1',
+ type: 'mqtt_private',
+ name: 'My MQTT',
+ enabled: true,
+ config: { broker_host: 'localhost', broker_port: 1883 },
+ scope: { messages: 'none', raw_packets: 'all' },
+ sort_order: 0,
+ created_at: 1000,
+ };
+ mockedApi.getFanoutConfigs.mockResolvedValue([mqtt]);
+ renderSection();
+ await waitFor(() => expect(screen.getByText('My MQTT')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
+
+ expect(screen.queryByText(/will not forward any data/)).not.toBeInTheDocument();
+ });
+
it('navigates to create view when clicking add button', async () => {
const createdWebhook: FanoutConfig = {
id: 'wh-new',