diff --git a/core/node_controls.py b/core/node_controls.py index b5a1e78..b05703a 100644 --- a/core/node_controls.py +++ b/core/node_controls.py @@ -37,6 +37,8 @@ def perform(action, payload, config): if not _LOCK.acquire(blocking=False): return 409, {'ok': False, 'error': 'Another node control is being verified; wait before retrying'} try: + if action == 'node-connect' and isinstance(payload, dict) and 'replace_current' in payload: + return connect_replacing_current(payload, config) if action == 'node-switch': return switch(payload, config) return perform_locked(action, payload, config) @@ -44,6 +46,29 @@ def perform(action, payload, config): _LOCK.release() +def connect_replacing_current(payload, config): + """Select a single current peer from fresh radio evidence under the control lock.""" + local = str(config.get('node', '')) + if (set(payload) != {'node', 'replace_current'} or payload['replace_current'] is not True + or not numeric(local) or not numeric(payload['node']) or payload['node'] == local): + return 400, {'ok': False, 'error': 'Enter a valid remote node number and switch option'} + observed = asterisk_observation.node_evidence(local) + links = observed.get('links') + if (observed.get('status') != 'available' or not asterisk_observation.fresh(observed) + or not isinstance(links, list) or any(not isinstance(link, dict) + or not numeric(link.get('node')) or link.get('mode') not in ('T', 'R', 'C') for link in links)): + return 503, {'ok': False, 'error': 'Current connections could not be verified. No switch was attempted.'} + peers = {link['node'] for link in links} + if payload['node'] in peers: + return perform_locked('node-connect', {'node': payload['node']}, config) + if len(peers) > 1: + return 409, {'ok': False, 'reason': 'choose_current_node', 'connected_nodes': sorted(peers), + 'error': 'Several nodes are connected. Choose which node to leave.'} + if not peers or peers == {payload['node']}: + return perform_locked('node-connect', {'node': payload['node']}, config) + return switch({'node': payload['node'], 'from_node': next(iter(peers))}, config) + + def switch(payload, config): """Called under the control lock; affect only the two explicit targets.""" local = str(config.get('node', '')) diff --git a/core/test_auto_switch.js b/core/test_auto_switch.js new file mode 100644 index 0000000..c8ee824 --- /dev/null +++ b/core/test_auto_switch.js @@ -0,0 +1,44 @@ +'use strict'; +const {chromium} = require('playwright'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const html = fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8'); +(async()=>{ + const browser=await chromium.launch({headless:true,...(process.env.BLUENODE_BROWSER_PATH?{executablePath:process.env.BLUENODE_BROWSER_PATH}:{})}); + try { + const page=await browser.newPage(); const posts=[]; let multiple=false, choice='11111'; + page.on('dialog',d=>choice===null?d.dismiss():d.accept(choice)); + await page.route('**/*',async route=>{ + const req=route.request(),url=new URL(req.url()); + if(url.pathname==='/web/') return route.fulfill({contentType:'text/html',body:html}); + let body={},status=200; + if(req.method()==='POST'){ + const payload=JSON.parse(req.postData()); posts.push({path:url.pathname,payload}); + if(multiple && payload.replace_current){status=409;body={ok:false,reason:'choose_current_node',connected_nodes:['11111','22222']};} + else body={ok:true,outcome:'verified',node:payload.node,message:'Completed'}; + } else if(url.pathname==='/api/admin/session') body={enabled:false,authenticated:false}; + else {status=404;} + await route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); + }); + const connect=page.locator('button[onclick="runNodeControl(\'node-connect\', this)"]'); + const setup=async()=>{await page.goto('http://bluenode.test/web/');await page.evaluate(()=>configureSavedNodes('99999',{}));await page.locator('#manual-node-number').fill('33333');}; + const click=async()=>{await connect.click();await page.waitForFunction(()=>!document.querySelector('button[onclick="runNodeControl(\'node-connect\', this)"]').disabled);}; + await setup();assert.equal(await page.locator('#auto-disconnect-current').isChecked(),false); + assert.equal(await page.locator('#switch-from-node').count(),0); + await click();assert.deepEqual(posts.at(-1).payload,{node:'33333'}); + await page.locator('#auto-disconnect-current').check();await click();assert.deepEqual(posts.at(-1).payload,{node:'33333',replace_current:true}); + await setup();assert.equal(await page.locator('#auto-disconnect-current').isChecked(),true,'preference survives reload'); + await page.evaluate(()=>configureSavedNodes('88888',{}));assert.equal(await page.locator('#auto-disconnect-current').isChecked(),false,'preference is scoped'); + await page.evaluate(()=>configureSavedNodes('99999',{}));assert.equal(await page.locator('#auto-disconnect-current').isChecked(),true); + multiple=true;await click();assert.deepEqual(posts.at(-1),{path:'/api/control/node-switch',payload:{node:'33333',from_node:'11111'}}); + choice=null;let count=posts.length;await click();assert.equal(posts.length,count+1,'cancel sends no switch');assert.match(await page.locator('#control-result').textContent(),/cancelled/); + choice='77777';count=posts.length;await click();assert.equal(posts.length,count+1,'invalid selection sends no switch'); + multiple=false;await page.locator('#favorite-node-label').fill('Favorite');await page.getByRole('button',{name:'Save node as favorite',exact:true}).click(); + await page.locator('#saved-node-favorites button[onclick^="connectSavedNode"]').click(); + await page.waitForFunction(()=>!document.querySelector('#saved-node-favorites button[onclick^="connectSavedNode"]').disabled); + assert.deepEqual(posts.at(-1).payload,{node:'33333'},'favorites do not inherit manual switch setting'); + await page.locator('#auto-disconnect-current').uncheck();await click();assert.deepEqual(posts.at(-1).payload,{node:'33333'}); + console.log('PASS automatic switch preference, persistence, scope, multi-peer selection/cancel, and independent favorite controls'); + } finally {await browser.close();} +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/core/test_control_auth.js b/core/test_control_auth.js index dddf1fd..9bcfd4a 100644 --- a/core/test_control_auth.js +++ b/core/test_control_auth.js @@ -71,13 +71,12 @@ const html = fs.readFileSync(path.join(__dirname, '../web/index.html'), 'utf8'); ['#btn-dodropin-connect','dodropin-connect'], ['#btn-dodropin-disconnect','dodropin-disconnect'], ['button[onclick="runNodeControl(\'node-connect\', this)"]','node-connect'], ['button[onclick="runNodeControl(\'node-disconnect\', this)"]','node-disconnect'], - ['button[onclick="runNodeControl(\'node-switch\', this)"]','node-switch'], ['#emergency-enter','emergency-enable'], ['button[onclick="toggleEmergencyMode(false, this)"]','emergency-disable'], ['#maintenance-toggle','maintenance-enable'] ]; await page.locator('#manual-node-number').fill('12345'); - await page.locator('#switch-from-node').fill('54321'); + await page.locator('#auto-disconnect-current').check(); for (const [selector, action] of controls) { authenticated = false; // Includes expiry while the page still believes it is signed in. const count = executed.length; @@ -90,11 +89,6 @@ const html = fs.readFileSync(path.join(__dirname, '../web/index.html'), 'utf8'); assert.match(pendingLabel, /This action will run after sign-in/); assert.ok(!pendingLabel.includes(action), 'Do not expose internal action identifiers'); if (action.startsWith('node-')) assert.ok(pendingLabel.includes('12345')); - if (action === 'node-switch') { - assert.ok(pendingLabel.includes('54321')); - await page.locator('#switch-from-node').fill('77777'); - await page.locator('#manual-node-number').fill('88888'); - } // A second control cannot replace or duplicate the first one. await page.evaluate(() => runControl('skywarn-disable', document.createElement('button'))); await login(); @@ -103,7 +97,7 @@ const html = fs.readFileSync(path.join(__dirname, '../web/index.html'), 'utf8'); assert.match(await page.locator('#pending-control-result').textContent(), /Completed:.*Fixture completed/); assert.equal(await page.locator('#control-login-cancel').isVisible(), false); if (action.startsWith('node-')) assert.deepEqual(executed.at(-1).payload, - action === 'node-switch' ? {node:'12345',from_node:'54321'} : {node:'12345'}); + action === 'node-connect' ? {node:'12345',replace_current:true} : {node:'12345'}); await page.locator('#manual-node-number').fill('12345'); await page.evaluate(() => adminLogin(document.createElement('button'))); assert.equal(executed.length, count + 1, 'another login must not replay consumed action'); @@ -212,6 +206,6 @@ const html = fs.readFileSync(path.join(__dirname, '../web/index.html'), 'utf8'); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'config-error overflow at ' + width); } assert.deepEqual(errors, []); - console.log('PASS control auth UX: eight controls; immutable switch targets, resume once, expiry, reload, logout, failure, cancellation, CSRF and injection'); + console.log('PASS control auth UX: controls; automatic-switch option through sign-in, resume once, expiry, reload, logout, failure, cancellation, CSRF and injection'); } finally { await browser.close(); } })().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/core/test_control_routing.js b/core/test_control_routing.js index f55b67d..addfa0e 100644 --- a/core/test_control_routing.js +++ b/core/test_control_routing.js @@ -52,7 +52,6 @@ const html = fs.readFileSync(path.join(__dirname, '../web/index.html'), 'utf8'); await page.evaluate(() => loadAdminSession()); await page.evaluate(() => { window.loginCalls = 0; window.adminLogin = () => { window.loginCalls++; }; }); await page.locator('#manual-node-number').fill('12345'); - await page.locator('#switch-from-node').fill('54321'); await page.evaluate(()=>configureSavedNodes('99999',{})); await page.locator('#favorite-node-label').fill('Example favorite'); await page.getByRole('button',{name:'Save node as favorite',exact:true}).click(); @@ -66,7 +65,6 @@ const html = fs.readFileSync(path.join(__dirname, '../web/index.html'), 'utf8'); ['#btn-dodropin-connect','dodropin-connect'], ['#btn-dodropin-disconnect','dodropin-disconnect'], ['button[onclick="runNodeControl(\'node-connect\', this)"]','node-connect'], ['button[onclick="runNodeControl(\'node-disconnect\', this)"]','node-disconnect'], - ['button[onclick="runNodeControl(\'node-switch\', this)"]','node-switch'], ['#saved-node-favorites button[onclick^="connectSavedNode"]','node-connect'], ['#emergency-enter','emergency-enable'], ['#maintenance-toggle','maintenance-enable'] ]; diff --git a/core/test_node_controls.py b/core/test_node_controls.py index acc6965..5340676 100644 --- a/core/test_node_controls.py +++ b/core/test_node_controls.py @@ -189,6 +189,54 @@ def test_bad_switch_targets_never_observe_or_execute(self): self.run.assert_not_called() +class AutomaticSwitchTests(unittest.TestCase): + setUp = NodeControlTests.setUp + samples = SwitchTests.samples + + def automatic(self, **extra): + return controls.perform('node-connect', dict(node='50241', replace_current=True, **extra), CONFIG) + + def test_automatic_switch_uses_fresh_peer_and_verified_order(self): + self.probe.side_effect = [self.samples(('11111', 'T')), self.samples(('11111', 'T')), + evidence(), evidence(), evidence('T'), evidence('T')] + code, result = self.automatic() + self.assertEqual((code, result['phase']), (200, 'complete')) + self.assertEqual([call.args[0][-1] for call in self.run.call_args_list], + ['rpt fun 23456 *111111', 'rpt fun 23456 *350241']) + + def test_no_peer_connects_normally(self): + self.probe.side_effect = [evidence(), evidence(), evidence('T')] + self.assertEqual(self.automatic()[0], 200) + self.assertEqual([call.args[0][-1] for call in self.run.call_args_list], ['rpt fun 23456 *350241']) + + def test_same_target_never_disconnects_even_with_other_links(self): + self.probe.return_value = self.samples(('50241', 'T'), ('33333', 'T')) + self.assertEqual(self.automatic()[1]['outcome'], 'already_satisfied') + self.run.assert_not_called() + + def test_several_peers_require_explicit_selection(self): + self.probe.return_value = self.samples(('11111', 'T'), ('33333', 'C')) + code, result = self.automatic() + self.assertEqual((code, result['reason']), (409, 'choose_current_node')) + self.assertEqual(result['connected_nodes'], ['11111', '33333']) + self.run.assert_not_called() + + def test_stale_missing_or_malformed_evidence_never_disconnects(self): + for sample in ({'status':'unavailable'}, dict(evidence(), observed_at=0), + dict(evidence(), links=[{}]), dict(evidence(), links=None)): + self.probe.return_value = sample + self.assertEqual(self.automatic()[0], 503) + self.run.assert_not_called() + + def test_invalid_auto_payload_never_observes(self): + for payload in ({'node':'50241','replace_current':False}, {'node':'23456','replace_current':True}, + {'node':'bad','replace_current':True}, {'node':'50241','replace_current':'true'}, + {'node':'50241','replace_current':True,'extra':1}): + self.assertEqual(controls.perform('node-connect', payload, CONFIG)[0], 400) + self.probe.assert_not_called() + self.run.assert_not_called() + + class DiagnosticStorageTests(unittest.TestCase): def test_writes_structured_record_and_handles_unwritable_storage(self): with tempfile.TemporaryDirectory() as folder: diff --git a/docs/DASHBOARD_GUIDE.md b/docs/DASHBOARD_GUIDE.md index ee8fc09..257135b 100644 --- a/docs/DASHBOARD_GUIDE.md +++ b/docs/DASHBOARD_GUIDE.md @@ -21,7 +21,7 @@ Start here after installation, or return whenever a label is unfamiliar. Some op Favorites are stored in this browser, separately for each local node. Another browser, device, or dashboard address can have a different list. Clearing browser data can remove them. If storage is blocked, the dashboard explains that changes last only for that page. -**Which number gets saved?** The main number beside Connect and Disconnect. The number beside Switch nodes is the node you want to leave; it is not the number saved as a favorite. +**Which number gets saved?** The main number beside Connect and Disconnect. The automatic-disconnect option does not change which number is saved. ## Station and health cards @@ -38,9 +38,10 @@ Favorites are stored in this browser, separately for each local node. Another br ## Connection controls -- **Connect:** adds a link to the number in the main node box. Other links stay connected. +**Disconnect the current node when I connect to a new one:** an optional checkbox above manual Connect, off by default and remembered in this browser for each local node. With one link, Connect disconnects that node first and then connects to your new number. With no links, it connects normally. With several links, it asks you to enter the number of the node to leave; the other links stay connected. Connecting to the same node does not disconnect it. Favorites and Recent Nodes buttons keep their usual behavior. If disconnect fails, the new connection is not attempted; if the new connection fails after disconnecting, the old link is not restored automatically. + +- **Connect:** connects to the number in the main node box. Other links stay connected unless you enable the automatic-disconnect option above. - **Disconnect:** leaves the number in the main node box. It does not mean disconnect everything. -- **Switch nodes:** leaves the number in the Node to leave box, then connects to the number in the main box above. Other links stay connected. Read the result if either step fails. - **DODROPIN controls:** shortcuts for that configured destination, where available. - **Search AllStar Nodes:** looks up a node, callsign, or location in the directory. A listing does not guarantee the node is online or accepting connections. - **Close Results:** hides the directory results; it does not disconnect a node. diff --git a/web/guide.html b/web/guide.html index 476ef79..442356d 100644 --- a/web/guide.html +++ b/web/guide.html @@ -21,11 +21,12 @@

Saved nodes, Favorites, and Rece

These are different lists. Saving a favorite does not add it to a preset dropdown.

Favorites are stored in this browser, separately for each local node. Another browser, device, or dashboard address can have a different list. Clearing browser data can remove them. If storage is blocked, the dashboard explains that changes last only for that page.

-

Which number gets saved? The main number beside Connect and Disconnect. The number beside Switch nodes is the node you want to leave; it is not the number saved as a favorite.

+

Which number gets saved? The main number beside Connect and Disconnect. The automatic-disconnect option does not change which number is saved.

Station and health cards

Connection controls

- +

Disconnect the current node when I connect to a new one: an optional checkbox above manual Connect, off by default and remembered in this browser for each local node. With one link, Connect disconnects that node first and then connects to your new number. With no links, it connects normally. With several links, it asks you to enter the number of the node to leave; the other links stay connected. Connecting to the same node does not disconnect it. Favorites and Recent Nodes buttons keep their usual behavior. If disconnect fails, the new connection is not attempted; if the new connection fails after disconnecting, the old link is not restored automatically.

+

When Remote Admin is required, a control may ask you to sign in first. Review the pending action before continuing. Saving a favorite is a browser preference, not a radio command.

Connection statistics and history

diff --git a/web/index.html b/web/index.html index 99e013d..b710f5f 100644 --- a/web/index.html +++ b/web/index.html @@ -1154,6 +1154,11 @@

Favorites & Recent Nodes

Manual Node Connection

+ +

Applies to the Connect button below. With several links, you will be asked which one to leave.

@@ -1198,16 +1203,6 @@

Manual Node Connection

-
-

Switch nodes

-

Disconnect only the node below, then connect to the number entered above. Other links stay connected.

-
- - -
-
-
@@ -1536,6 +1531,11 @@

Recent Events

} if (!ordinaryActions.has(action)) throw new Error('Unsupported control'); const keys = Object.keys(payload); + if (action === 'node-connect' && payload.replace_current === true) { + if (keys.length !== 2 || !keys.includes('node') || typeof payload.node !== 'string' || + !/^\d{1,10}$/.test(payload.node)) throw new Error('Invalid node number'); + return Object.freeze({...payload}); + } if (action === 'node-switch') { if (keys.length !== 2 || !keys.includes('node') || !keys.includes('from_node') || ![payload.node, payload.from_node].every(value => typeof value === 'string' && /^\d{1,10}$/.test(value)) || @@ -1555,7 +1555,7 @@

Recent Events

'maintenance-enable':'enable Maintenance Mode', 'maintenance-disable':'disable Maintenance Mode', 'admin-refresh-diagnostics':'refresh diagnostics', 'admin-restart-monitor':'restart the BlueNode monitor', 'admin-restart-asterisk':'restart Asterisk'}; - return action === 'node-connect' ? 'connect to node ' + payload.node : + return action === 'node-connect' ? (payload.replace_current ? 'disconnect the current node, then connect to node ' : 'connect to node ') + payload.node : action === 'node-switch' ? 'disconnect node ' + payload.from_node + ' and connect to node ' + payload.node : action === 'node-disconnect' ? 'disconnect node ' + payload.node : labels[action]; } @@ -1835,8 +1835,10 @@

Recent Events

const resultElement = document.getElementById('control-result'); const node = selectedNode === null ? input.value.trim() : selectedNode; - const payload = action === 'node-switch' ? - {node, from_node:document.getElementById('switch-from-node').value.trim()} : {node}; + const payload = {node}; + if (action === 'node-connect' && selectedNode === null && document.getElementById('auto-disconnect-current').checked) { + payload.replace_current = true; + } const originalText = button.textContent; @@ -1864,9 +1866,20 @@

Recent Events

try { - const response = await requestOrdinaryControl(action, payload); - - const result = await response.json(); + let response = await requestOrdinaryControl(action, payload); + + let result = await response.json(); + if (payload.replace_current && response.status === 409 && result.reason === 'choose_current_node') { + const peers = result.connected_nodes; + if (!Array.isArray(peers) || !peers.length || !peers.every(validSavedNode)) throw new Error('Current connections could not be verified.'); + const choice = prompt('Several nodes are connected: ' + peers.join(', ') + '.\nEnter the node number to disconnect before connecting to ' + node + ':'); + if (choice === null) { resultElement.textContent = 'Connection cancelled. Existing links were left connected.'; return; } + const source = choice.trim(); + if (!peers.includes(source) || source === node) throw new Error('Enter one of the connected node numbers shown. Nothing was disconnected.'); + if (controlNodeScope !== savedNodeScope) throw new Error('Local node changed. Please try again.'); + response = await requestOrdinaryControl('node-switch', {node, from_node:source}); + result = await response.json(); + } @@ -2555,11 +2568,25 @@

Recent Events

renderSavedNodes(); } + function saveAutoDisconnectPreference() { + if (!savedNodeScope) return; + try { + localStorage.setItem('bluenode:auto-disconnect:v1:' + savedNodeScope, + String(document.getElementById('auto-disconnect-current').checked)); + document.getElementById('saved-nodes-message').textContent = 'Connection preference saved in this browser.'; + } catch (_) { + document.getElementById('saved-nodes-message').textContent = 'Connection preference changed for this page only; browser storage is unavailable.'; + } + } + function configureSavedNodes(local, names) { if (!validSavedNode(local)) return; savedNodeNames = names && typeof names === 'object' ? names : {}; if (savedNodeScope !== local) { savedNodeScope = local; + const autoDisconnect = document.getElementById('auto-disconnect-current'); + autoDisconnect.checked = false; + try { autoDisconnect.checked = localStorage.getItem('bluenode:auto-disconnect:v1:' + local) === 'true'; } catch (_) {} savedNodeData = {favorites:[],recent:[]}; savedNodeStorageAvailable = true; try {