Add broker management and writer transfer UI

This commit is contained in:
2026-09-13 13:59:28 +02:00
parent 29a4953df0
commit fa12440606
26 changed files with 1037 additions and 48 deletions
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
const path = '/api/settings/broker', op = path + '-operation';
const row = (id, type = 1) => ({id, type, name_hex:'3c696d673e', pending:12, high_water:4096, dropped:'18446744073709551615'});
const fixture = (extra = {}) => ({generation:7, writer:8, clients:[row(8),row(9,3)], ...extra});
const reply = (state='pending', id=42, status=200, action='assign') => new Response(JSON.stringify({id,action,state}), {status});
const n = (b,id) => b.nodes['broker-'+id];
const posts = b => b.calls.filter(c => c.url === op && c.method === 'POST');
const gets = b => b.calls.filter(c => c.url === op && c.method === 'GET');
const reads = b => b.calls.filter(c => c.url === path);
async function open(value=fixture()) {
const b=await adminBrowser(); b.click('select-settings'); await tick();
b.queues[path].push(json(value)); b.click('settings-broker'); await tick(); return b;
}
function select(b,id=9) { n(b,'target').value=String(id); n(b,'target').change(); }
async function submit(b) { select(b); b.window.confirm=()=>true; b.queues[op].push(reply('pending',42,202)); b.click('broker-assign'); await tick(); }
await test('Broker admin-only full page, label/value style, bounded safe rows; view/selection do not mutate or close either terminal', async()=>{
for(const id of ['settings-broker','broker-values','broker-target','broker-assign','broker-refresh','broker-result']) assert.ok(html.includes('id="'+id+'"'));
const u=browser(); u.start(); await tick(); u.click('settings-broker'); await tick(); assert.equal(reads(u).length,0);
const b=await open(); assert.equal(b.nodes['broker-settings'].hidden,false); assert.equal(b.nodes['display-settings'].hidden,true);
assert.match(n(b,'values').textContent,/<img>/); assert.match(n(b,'values').textContent,/18446744073709551615/);
select(b); assert.equal(posts(b).length,0); assert.equal(n(b,'assign').disabled,false);
const count=b.calls.length; b.click('settings-broker'); await tick(); assert.equal(b.calls.length,count);
for(let i=0;i<2;++i){ b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer}); assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]); b.terminals[i].input('blocked'); assert.equal(b.sockets[i].sent.length,0); }
assert.ok(b.sockets.every(s=>!s.closed));
});
await test('Broker snapshot rejects malformed/missing/duplicate/oversized rows and exhausted generation disables assignments',async()=>{
for(const v of [null,{},fixture({generation:0}),fixture({extra:1}),fixture({writer:123}),fixture({clients:[row(8),row(8)]}),fixture({clients:[{...row(8),name_hex:'zz'}]}),fixture({clients:[{...row(8),dropped:'18446744073709551616'}]}),fixture({clients:[{...row(8),pending:4097}]}),fixture({clients:Array.from({length:9},(_,i)=>row(i+8))})]){
const b=await open(v); select(b); assert.ok(n(b,'assign').disabled); assert.match(n(b,'detail').textContent,/unavailable|invalid/);
}
const b=await open(fixture({generation:4294967295})); select(b); assert.ok(n(b,'assign').disabled);
const empty=await open(fixture({writer:0,clients:[]})); assert.match(n(empty,'detail').textContent,/0 connected/);
const c=await open(); c.queues[path].push(new Response('x'.repeat(2049))); c.click('broker-refresh'); await tick(); select(c); assert.ok(n(c,'assign').disabled);
});
await test('Broker explicit confirmation captures target and lease generation, cancel/current-writer/selection never POST; refresh clears selection',async()=>{
const b=await open(); select(b,8); b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0);
select(b); let prompt=''; b.window.confirm=text=>{prompt=text;return false;}; b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0); assert.match(prompt,/9 \/ SSH/); assert.match(prompt,/Current writer: 8/);
await submit(b); assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'assign',generation:7,target:9}); assert.equal(posts(b)[0].headers['X-CSRF-Token'],token);
b.click('broker-assign'); await tick(); assert.equal(posts(b).length,1);
b.queues[op].push(reply('ok')); b.queues[path].push(json(fixture({generation:8,writer:9}))); b.fire(1000); await tick();
assert.equal(n(b,'target').value,''); assert.ok(n(b,'assign').disabled); assert.match(n(b,'operation-detail').textContent,/completed/); assert.equal(posts(b).length,1);
});
await test('Broker stale/failed/cancelled completion refreshes without retry; stale target removal clears explicit selection',async()=>{
for(const state of ['conflict','failed','cancelled']) {
const b=await open(); await submit(b); b.queues[op].push(reply(state)); b.queues[path].push(json(fixture({clients:[row(8)]}))); b.fire(1000); await tick();
assert.equal(posts(b).length,1); assert.equal(reads(b).length,2); assert.equal(n(b,'target').value,''); assert.ok(n(b,'assign').disabled);
if(state==='conflict') assert.match(n(b,'operation-detail').textContent,/No lease change/);
}
});
await test('Broker polling bounded to ten requests/fifteen seconds, including stalled session checks',async()=>{
const b=await open(); await submit(b);
for(let i=0;i<10;++i){b.queues[op].push(reply());b.fire(1000);await tick();}
assert.equal(gets(b).length,10);assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Automatic checking stopped/);
const c=await open();await submit(c);const d=deferred();c.queues['/api/session'].push(d.promise);c.fire(1000);await tick();c.elapse(15000);c.fire(15000);await tick();d.resolve(session({role:'admin',username:'alice'}));await tick();assert.equal(gets(c).length,0);
});
await test('Broker lost acknowledgement/replaced result/invalid state preserve uncertainty and never replay',async()=>{
const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('broker-assign');await tick();
assert.match(n(b,'operation-detail').textContent,/unknown/);assert.ok(n(b,'assign').disabled);
b.queues[op].push(reply('ok'));b.queues[path].push(json(fixture()));b.click('broker-result');await tick();assert.match(n(b,'operation-detail').textContent,/acknowledgement was lost/);
for(const result of [reply('pending',43),reply('loaded_defaults'),reply('ok',42,202),reply('ok',42,200,'reset'),reply('idle',42)]){
const c=await open();await submit(c);c.queues[op].push(result);c.fire(1000);await tick();assert.equal(posts(c).length,1);assert.match(n(c,'operation-detail').textContent,/unknown/);assert.ok(![...c.timers.values()].some(t=>t.ms===1000||t.ms===15000));
}
});
await test('Broker navigation aborts stale read/POST/results, no automatic resubmit on return; endpoint401 closes both terminals',async()=>{
for(const stage of ['read','post','result']){
const b=await open();const d=deferred();
if(stage==='read'){b.queues[path].push(d.promise);b.click('broker-refresh');}
else if(stage==='post'){select(b);b.window.confirm=()=>true;b.queues[op].push(d.promise);b.click('broker-assign');}
else{await submit(b);b.queues[op].push(d.promise);b.fire(1000);}
await tick();b.click('settings-display');await tick();d.resolve(failure(401));await tick();assert.deepEqual(b.redirects,[]);assert.ok(b.sockets.every(s=>!s.closed));
const count=posts(b).length;b.queues[path].push(json(fixture()));b.click('settings-broker');await tick();assert.equal(posts(b).length,count);assert.equal(n(b,'target').value,'');
}
const b=await open();b.queues[path].push(failure(401));b.click('broker-refresh');await tick();assert.deepEqual(b.redirects,['/login']);assert.ok(b.sockets.every(s=>s.closed));
});
await test('Broker pagehide/expiry/logout clear selected identities and stop checks without cancellation claims',async()=>{
for(const action of ['pagehide','expiry','logout']){
const b=await open();await submit(b);
if(action==='pagehide')b.emit('pagehide');else if(action==='expiry')b.window.sakSessionExpired();else{b.queues['/api/logout'].push(new Response(null,{status:204}));b.click('sign-out');}
await tick();assert.equal(n(b,'target').value,'');assert.equal(n(b,'values').textContent,'');assert.equal(posts(b).length,1);assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
}
});
};
+2 -1
View File
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -1252,5 +1252,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
});
await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+5 -5
View File
@@ -45,10 +45,10 @@ def check_layout(html):
if cls in classes(node):
return node
raise AssertionError(cls)
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values'):
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values'):
assert ids[ident]['tag'] == 'dl'
assert 'settings-values' in classes(ids[ident])
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings'):
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings'):
nodes = list(descendants(ids[ident]))
assert not any(n['tag'] == 'pre' for n in nodes)
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
@@ -59,9 +59,9 @@ def check_layout(html):
ancestor(n, 'settings-edit')
except AssertionError:
ancestor(n, 'serial-edit')
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh'):
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh'):
assert ids[ident]['text'] == 'Refresh'
for ident in ('serial-result', 'account-result', 'network-result', 'display-result'):
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result'):
assert ids[ident]['text'] == 'Check Operation Result'
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
assert 'settings-check' in classes(ids[ident]['parent'])
@@ -90,7 +90,7 @@ def check_layout(html):
):
assert rule in css, rule
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all four settings views')
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all five settings views')
def check_browser_layout(html, tmp, executable):