diff --git a/frontend/src/components/CrackerPanel.tsx b/frontend/src/components/CrackerPanel.tsx index d6c15ab..8336fe1 100644 --- a/frontend/src/components/CrackerPanel.tsx +++ b/frontend/src/components/CrackerPanel.tsx @@ -39,6 +39,7 @@ export function CrackerPanel({ }: CrackerPanelProps) { const [isRunning, setIsRunning] = useState(false); const [maxLength, setMaxLength] = useState(6); + const [maxLengthInput, setMaxLengthInput] = useState('6'); const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false); const [decryptHistorical, setDecryptHistorical] = useState(true); const [turboMode, setTurboMode] = useState(false); @@ -191,6 +192,10 @@ export function CrackerPanel({ maxLengthRef.current = maxLength; }, [maxLength]); + useEffect(() => { + setMaxLengthInput(String(maxLength)); + }, [maxLength]); + useEffect(() => { decryptHistoricalRef.current = decryptHistorical; }, [decryptHistorical]); @@ -434,8 +439,25 @@ export function CrackerPanel({ type="number" min={1} max={10} - value={maxLength} - onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))} + value={maxLengthInput} + onChange={(e) => { + const nextValue = e.target.value; + setMaxLengthInput(nextValue); + if (nextValue === '') return; + const parsed = Number.parseInt(nextValue, 10); + if (Number.isNaN(parsed)) return; + setMaxLength(Math.min(10, Math.max(1, parsed))); + }} + onBlur={() => { + const parsed = Number.parseInt(maxLengthInput, 10); + const nextValue = Number.isNaN(parsed) + ? maxLength + : Math.min(10, Math.max(1, parsed)); + setMaxLengthInput(String(nextValue)); + if (nextValue !== maxLength) { + setMaxLength(nextValue); + } + }} className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded" /> diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index f6f08d3..a4f7e58 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -316,6 +316,80 @@ const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries( CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition]) ) as Record; +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 +): Record { + 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; } @@ -338,7 +412,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record) { @@ -649,9 +726,9 @@ function MqttPrivateConfigEditor({ type="number" min="1" max="65535" - value={(config.broker_port as number) || 1883} + value={getNumberInputValue(config.broker_port, 1883)} onChange={(e) => - onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 1883 }) + onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) }) } /> @@ -709,7 +786,8 @@ function MqttPrivateConfigEditor({ onChange({ ...config, topic_prefix: e.target.value })} /> @@ -745,7 +823,7 @@ function MqttCommunityConfigEditor({ id="fanout-comm-host" type="text" placeholder={DEFAULT_COMMUNITY_BROKER_HOST} - value={(config.broker_host as string) || DEFAULT_COMMUNITY_BROKER_HOST} + value={(config.broker_host as string | undefined) ?? ''} onChange={(e) => onChange({ ...config, broker_host: e.target.value })} /> @@ -756,11 +834,11 @@ function MqttCommunityConfigEditor({ type="number" min="1" max="65535" - value={(config.broker_port as number) || DEFAULT_COMMUNITY_BROKER_PORT} + value={getNumberInputValue(config.broker_port, DEFAULT_COMMUNITY_BROKER_PORT)} onChange={(e) => onChange({ ...config, - broker_port: parseInt(e.target.value, 10) || DEFAULT_COMMUNITY_BROKER_PORT, + broker_port: parseIntegerInputValue(e.target.value), }) } /> @@ -895,7 +973,8 @@ function MqttCommunityConfigEditor({ onChange({ ...config, topic_template: e.target.value })} />

@@ -1215,11 +1294,11 @@ function MapUploadConfigEditor({ min="0" step="any" placeholder="e.g. 100" - value={(config.geofence_radius_km as number | undefined) ?? ''} + value={getOptionalNumberInputValue(config.geofence_radius_km)} onChange={(e) => onChange({ ...config, - geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value), + geofence_radius_km: parseFloatInputValue(e.target.value), }) } /> @@ -1997,9 +2076,10 @@ export function SettingsFanoutSection({ if (!currentEditingId) { throw new Error('Missing fanout config id for update'); } + const editingType = configs.find((cfg) => cfg.id === currentEditingId)?.type ?? ''; const update: Record = { name: editName, - config: editConfig, + config: normalizeIntegrationConfigForSave(editingType, editConfig), scope: editScope, }; if (enabled !== undefined) update.enabled = enabled; diff --git a/frontend/src/components/visualizer/VisualizerControls.tsx b/frontend/src/components/visualizer/VisualizerControls.tsx index a27ca85..62cffa8 100644 --- a/frontend/src/components/visualizer/VisualizerControls.tsx +++ b/frontend/src/components/visualizer/VisualizerControls.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { Checkbox } from '../ui/checkbox'; import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils'; import { NODE_LEGEND_ITEMS } from './shared'; @@ -71,6 +72,19 @@ export function VisualizerControls({ onExpandContract, onClearAndReset, }: VisualizerControlsProps) { + const [observationWindowInput, setObservationWindowInput] = useState( + String(observationWindowSec) + ); + const [pruneWindowInput, setPruneWindowInput] = useState(String(pruneStaleMinutes)); + + useEffect(() => { + setObservationWindowInput(String(observationWindowSec)); + }, [observationWindowSec]); + + useEffect(() => { + setPruneWindowInput(String(pruneStaleMinutes)); + }, [pruneStaleMinutes]); + return ( <> {showControls && ( @@ -212,12 +226,25 @@ export function VisualizerControls({ type="number" min="1" max="60" - value={observationWindowSec} - onChange={(e) => - setObservationWindowSec( - Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1)) - ) - } + value={observationWindowInput} + onChange={(e) => { + const nextValue = e.target.value; + setObservationWindowInput(nextValue); + if (nextValue === '') return; + const parsed = Number.parseInt(nextValue, 10); + if (Number.isNaN(parsed)) return; + setObservationWindowSec(Math.max(1, Math.min(60, parsed))); + }} + onBlur={() => { + const parsed = Number.parseInt(observationWindowInput, 10); + const nextValue = Number.isNaN(parsed) + ? observationWindowSec + : Math.max(1, Math.min(60, parsed)); + setObservationWindowInput(String(nextValue)); + if (nextValue !== observationWindowSec) { + setObservationWindowSec(nextValue); + } + }} className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center" /> sec @@ -247,10 +274,25 @@ export function VisualizerControls({ type="number" min={1} max={60} - value={pruneStaleMinutes} + value={pruneWindowInput} onChange={(e) => { - const v = parseInt(e.target.value, 10); - if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v); + const nextValue = e.target.value; + setPruneWindowInput(nextValue); + if (nextValue === '') return; + const parsed = Number.parseInt(nextValue, 10); + if (Number.isNaN(parsed)) return; + if (parsed >= 1 && parsed <= 60) setPruneStaleMinutes(parsed); + }} + onBlur={() => { + const parsed = Number.parseInt(pruneWindowInput, 10); + const nextValue = + Number.isNaN(parsed) || parsed < 1 || parsed > 60 + ? pruneStaleMinutes + : parsed; + setPruneWindowInput(String(nextValue)); + if (nextValue !== pruneStaleMinutes) { + setPruneStaleMinutes(nextValue); + } }} className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm" /> diff --git a/frontend/src/test/crackerPanel.test.tsx b/frontend/src/test/crackerPanel.test.tsx new file mode 100644 index 0000000..8eb922c --- /dev/null +++ b/frontend/src/test/crackerPanel.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CrackerPanel } from '../components/CrackerPanel'; + +vi.mock('meshcore-hashtag-cracker', () => ({ + GroupTextCracker: class { + isGpuAvailable() { + return false; + } + destroy() {} + setWordlist() {} + abort() {} + }, +})); + +vi.mock('nosleep.js', () => ({ + default: class { + enable() {} + disable() {} + }, +})); + +vi.mock('../api', () => ({ + api: { + getUndecryptedPacketCount: vi.fn(), + }, +})); + +vi.mock('../components/ui/sonner', () => ({ + toast: { + error: vi.fn(), + }, +})); + +import { api } from '../api'; + +const mockedApi = vi.mocked(api); + +describe('CrackerPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedApi.getUndecryptedPacketCount.mockResolvedValue({ count: 0 }); + }); + + it('allows clearing max length while editing', async () => { + render(); + + await waitFor(() => { + expect(mockedApi.getUndecryptedPacketCount).toHaveBeenCalled(); + }); + + const maxLengthInput = screen.getByLabelText('Max Length:') as HTMLInputElement; + fireEvent.change(maxLengthInput, { target: { value: '' } }); + + expect(maxLengthInput.value).toBe(''); + }); +}); diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index bd2ee69..6446125 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -704,6 +704,75 @@ describe('SettingsFanoutSection', () => { expect(audienceInput).toHaveValue(''); }); + it('existing community MQTT defaults can be cleared while editing and normalize on save', async () => { + const communityConfig: FanoutConfig = { + id: 'comm-1', + type: 'mqtt_community', + name: 'Community Feed', + enabled: false, + config: { + broker_host: 'mqtt-us-v1.letsmesh.net', + broker_port: 443, + transport: 'websockets', + use_tls: true, + tls_verify: true, + auth_mode: 'token', + iata: 'LAX', + email: '', + token_audience: '', + topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets', + }, + scope: { messages: 'none', raw_packets: 'all' }, + sort_order: 0, + created_at: 1000, + }; + mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]); + mockedApi.updateFanoutConfig.mockResolvedValue({ + ...communityConfig, + enabled: true, + }); + + renderSection(); + await waitFor(() => expect(screen.getByText('Community Feed')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); + + const hostInput = screen.getByLabelText('Broker Host') as HTMLInputElement; + const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement; + const topicTemplateInput = screen.getByLabelText('Packet Topic Template') as HTMLInputElement; + + fireEvent.change(hostInput, { target: { value: '' } }); + fireEvent.change(portInput, { target: { value: '' } }); + fireEvent.change(topicTemplateInput, { target: { value: '' } }); + + expect(hostInput.value).toBe(''); + expect(portInput.value).toBe(''); + expect(topicTemplateInput.value).toBe(''); + + fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' })); + + await waitFor(() => + expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('comm-1', { + name: 'Community Feed', + config: { + broker_host: 'mqtt-us-v1.letsmesh.net', + broker_port: 443, + transport: 'websockets', + use_tls: true, + tls_verify: true, + auth_mode: 'token', + iata: 'LAX', + email: '', + token_audience: '', + topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets', + }, + scope: { messages: 'none', raw_packets: 'all' }, + enabled: true, + }) + ); + }); + it('community MQTT can be configured for no auth', async () => { const communityConfig: FanoutConfig = { id: 'comm-1', @@ -783,6 +852,65 @@ describe('SettingsFanoutSection', () => { expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument(); }); + it('private MQTT fields can be cleared while editing and normalize defaults on create', async () => { + const createdConfig: FanoutConfig = { + id: 'mqtt-private-1', + type: 'mqtt_private', + name: 'Private MQTT 1', + enabled: true, + config: { + broker_host: 'broker.local', + broker_port: 1883, + username: '', + password: '', + use_tls: false, + tls_insecure: false, + topic_prefix: 'meshcore', + }, + scope: { messages: 'all', raw_packets: 'all' }, + sort_order: 0, + created_at: 2000, + }; + mockedApi.createFanoutConfig.mockResolvedValue(createdConfig); + mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]); + + renderSection(); + await openCreateIntegrationDialog(); + selectCreateIntegration('Private MQTT'); + confirmCreateIntegration(); + await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); + + fireEvent.change(screen.getByLabelText('Broker Host'), { target: { value: 'broker.local' } }); + + const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement; + const prefixInput = screen.getByLabelText('Topic Prefix') as HTMLInputElement; + fireEvent.change(portInput, { target: { value: '' } }); + fireEvent.change(prefixInput, { target: { value: '' } }); + + expect(portInput.value).toBe(''); + expect(prefixInput.value).toBe(''); + + fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' })); + + await waitFor(() => + expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({ + type: 'mqtt_private', + name: 'Private MQTT #1', + config: { + broker_host: 'broker.local', + broker_port: 1883, + username: '', + password: '', + use_tls: false, + tls_insecure: false, + topic_prefix: 'meshcore', + }, + scope: { messages: 'all', raw_packets: 'all' }, + enabled: true, + }) + ); + }); + it('creates MeshRank preset as a regular mqtt_community config', async () => { const createdConfig: FanoutConfig = { id: 'comm-meshrank', @@ -912,6 +1040,57 @@ describe('SettingsFanoutSection', () => { ); }); + it('map upload geofence radius can be cleared while editing and normalizes to zero', async () => { + const createdConfig: FanoutConfig = { + id: 'map-1', + type: 'map_upload', + name: 'Map Upload 1', + enabled: true, + config: { + api_url: '', + dry_run: true, + geofence_enabled: true, + geofence_radius_km: 0, + }, + scope: { messages: 'none', raw_packets: 'all' }, + sort_order: 0, + created_at: 2000, + }; + mockedApi.createFanoutConfig.mockResolvedValue(createdConfig); + mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]); + + renderSection(); + await openCreateIntegrationDialog(); + selectCreateIntegration('Map Upload'); + confirmCreateIntegration(); + await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Enable Geofence')); + const radiusInput = screen.getByLabelText('Radius (km)') as HTMLInputElement; + + fireEvent.change(radiusInput, { target: { value: '100' } }); + fireEvent.change(radiusInput, { target: { value: '' } }); + + expect(radiusInput.value).toBe(''); + + fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' })); + + await waitFor(() => + expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({ + type: 'map_upload', + name: 'Map Upload #1', + config: { + api_url: '', + dry_run: true, + geofence_enabled: true, + geofence_radius_km: 0, + }, + scope: { messages: 'none', raw_packets: 'all' }, + enabled: true, + }) + ); + }); + it('LetsMesh (EU) preset saves the EU broker defaults', async () => { const createdConfig: FanoutConfig = { id: 'comm-letsmesh-eu', diff --git a/frontend/src/test/visualizerControls.test.tsx b/frontend/src/test/visualizerControls.test.tsx new file mode 100644 index 0000000..892b68d --- /dev/null +++ b/frontend/src/test/visualizerControls.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { VisualizerControls } from '../components/visualizer/VisualizerControls'; + +describe('VisualizerControls', () => { + it('allows clearing numeric inputs while editing', () => { + render( + + ); + + const observationInput = screen.getByLabelText('Ack/echo listen window:') as HTMLInputElement; + const pruneInput = screen.getByLabelText('Window:') as HTMLInputElement; + + fireEvent.change(observationInput, { target: { value: '' } }); + fireEvent.change(pruneInput, { target: { value: '' } }); + + expect(observationInput.value).toBe(''); + expect(pruneInput.value).toBe(''); + }); +});