Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions core/node_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,38 @@ 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)
finally:
_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', ''))
Expand Down
44 changes: 44 additions & 0 deletions core/test_auto_switch.js
Original file line number Diff line number Diff line change
@@ -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;});
12 changes: 3 additions & 9 deletions core/test_control_auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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');
Expand Down Expand Up @@ -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; });
2 changes: 0 additions & 2 deletions core/test_control_routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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']
];
Expand Down
48 changes: 48 additions & 0 deletions core/test_node_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions docs/DASHBOARD_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
Loading
Loading