Files
viktorиClaude Opus 4.8 cb4e8a9bb1 панель: многостраничная, Last Heard, SSE, контроль доступа
Панель:
 • шесть страниц с маршрутизацией по хэшу: Обзор, Last Heard, Хотспоты,
   Группы, Доступ, Настройки;
 • журнал приходит живым потоком (SSE) вместо опроса раз в две секунды;
 • Last Heard: кто, куда, когда, сколько, через какой хотспот;
 • в таблице хотспотов — ВСЁ, что устройство сообщает в RPTC.

Контроль доступа (два независимых уровня, их легко перепутать):
 • хотспот пускается по паролю — у каждого может быть свой, плюс режим
   «только из списка» и отключение конкретного устройства;
 • абонент (поле src, владелец рации) допускается белым/чёрным списком с
   поддержкой диапазонов. Встроено в штатный хук guard ядра.

Два бага по дороге:
 • поля RPTC читались как colorcode/latitude/longitude, а разборщик отдаёт
   color_code/lat/lon — половина таблицы молча пустовала;
 • столбец «описание» на деле содержит СТРАНУ: именно туда Pi-Star кладёт
   country. Переименован.

tests/test_access.py 20/20, tests/test_panel.py 31/31 (включая наполнение
Last Heard настоящим рукопожатием и голосовым вызовом по UDP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvX94Vwy7vXWEuiFm1g9Av
2026-07-29 01:32:46 +09:00

322 строки
19 KiB
Python

"""HTML панели управления — вынесено из panel.py, чтобы серверная логика читалась.
Одностраничное приложение с маршрутизацией по хэшу (#/overview и т.д.): страницы
разные, но файл один и без внешних зависимостей — на сервере нет ни pip, ни CDN.
Журнал приходит живым потоком (SSE), остальное обновляется опросом раз в 3 секунды.
"""
PAGE = r"""<!doctype html><html lang=ru><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>DMR-сервер</title>
<style>
:root{--bg:#0e1014;--panel:#151920;--fg:#e8eaed;--dim:#8b93a1;--line:#232833;
--acc:#4da3ff;--ok:#3ddc84;--warn:#ffb454;--err:#ff5f56;--radius:10px}
@media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--panel:#fff;--fg:#15181d;
--dim:#5c6472;--line:#e3e6ec;--acc:#0b63ce}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
a{color:inherit;text-decoration:none}
header{display:flex;align-items:center;gap:18px;padding:0 18px;height:52px;
background:var(--panel);border-bottom:1px solid var(--line);
position:sticky;top:0;z-index:10;flex-wrap:wrap}
.brand{font-weight:650;letter-spacing:.2px;white-space:nowrap}
nav{display:flex;gap:2px;flex:1;overflow-x:auto}
nav a{padding:7px 12px;border-radius:7px;color:var(--dim);white-space:nowrap;font-size:13px}
nav a:hover{background:rgba(127,127,127,.12);color:var(--fg)}
nav a.on{background:var(--acc);color:#fff}
.hstat{font-size:12px;color:var(--dim);white-space:nowrap}
main{padding:18px;max-width:1400px;margin:0 auto}
.page{display:none}.page.on{display:block}
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));margin-bottom:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px}
.card .n{font-size:26px;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.2}
.card .l{font-size:12px;color:var(--dim);margin-top:2px}
section{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
overflow:hidden;margin-bottom:16px}
h2{font-size:13px;margin:0;padding:11px 16px;border-bottom:1px solid var(--line);
font-weight:600;display:flex;gap:10px;align-items:center}
h2 .sub{font-weight:400;color:var(--dim);font-size:12px}
.body{padding:14px 16px}
.scroll{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:8px 16px;border-bottom:1px solid var(--line);
white-space:nowrap;font-variant-numeric:tabular-nums}
th{color:var(--dim);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
tr:last-child td{border-bottom:none}
tbody tr:hover{background:rgba(127,127,127,.06)}
.empty{color:var(--dim);padding:18px 16px;font-size:13px}
#log{max-height:420px;overflow-y:auto;padding:10px 16px;
font:12px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace}
#log div{white-space:pre-wrap;word-break:break-word}
.k-warn{color:var(--warn)}.k-error{color:var(--err)}.k-call{color:var(--ok)}
.dim{color:var(--dim)}
button{background:var(--acc);color:#fff;border:0;border-radius:7px;padding:7px 13px;
cursor:pointer;font-size:13px;font-family:inherit}
button:hover{filter:brightness(1.1)}
button.sec{background:transparent;color:var(--dim);border:1px solid var(--line);padding:5px 10px;font-size:12px}
button.danger{background:var(--err)}
input,select{background:var(--bg);color:var(--fg);border:1px solid var(--line);
border-radius:7px;padding:7px 10px;font-size:13px;font-family:inherit}
form{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
label{display:flex;gap:6px;align-items:center;color:var(--dim);font-size:13px}
.note{font-size:12px;color:var(--dim);margin:10px 0 0}
.pill{display:inline-block;padding:1px 8px;border-radius:20px;font-size:11px;border:1px solid var(--line)}
.pill.ok{color:var(--ok);border-color:var(--ok)}
.pill.off{color:var(--err);border-color:var(--err)}
.live{display:inline-flex;align-items:center;gap:6px}
.dot{width:7px;height:7px;border-radius:50%;background:var(--dim)}
.dot.on{background:var(--ok);box-shadow:0 0 0 3px rgba(61,220,132,.18)}
</style>
<header>
<span class=brand>DMR-сервер</span>
<nav>
<a href="#/overview">Обзор</a>
<a href="#/lastheard">Last Heard</a>
<a href="#/hotspots">Хотспоты</a>
<a href="#/groups">Группы</a>
<a href="#/access">Доступ</a>
<a href="#/settings">Настройки</a>
</nav>
<span class=hstat id=hstat></span>
</header>
<main>
<div class=page id=p-overview>
<div class=grid>
<div class=card><div class=n id=s-peers>—</div><div class=l>хотспотов на связи</div></div>
<div class=card><div class=n id=s-active>—</div><div class=l>идёт передач</div></div>
<div class=card><div class=n id=s-calls>—</div><div class=l>вызовов за сеанс</div></div>
<div class=card><div class=n id=s-up>—</div><div class=l>время работы</div></div>
</div>
<section><h2>Идёт сейчас</h2><div class=scroll><table id=t-active></table></div></section>
<section>
<h2>Журнал <span class="sub live"><span class="dot" id=livedot></span><span id=livetxt>подключаюсь…</span></span></h2>
<div id=log></div>
</section>
</div>
<div class=page id=p-lastheard>
<section><h2>Last Heard <span class=sub>последние передачи в эфире</span></h2>
<div class=scroll><table id=t-lh></table></div></section>
</div>
<div class=page id=p-hotspots>
<section><h2>На связи <span class=sub>всё, что хотспот сообщает о себе в RPTC</span></h2>
<div class=scroll><table id=t-peers></table></div></section>
<section><h2>Зарегистрированные</h2>
<div class=scroll><table id=t-hs></table></div>
<div class=body>
<form id=f-hs>
<input name=peer_id placeholder="номер (peer id)" size=14 required inputmode=numeric>
<input name=name placeholder="название" size=16>
<input name=password placeholder="свой пароль (пусто = общий)" size=24>
<button>Добавить</button>
</form>
<p class=note id=hs-note></p>
</div>
</section>
</div>
<div class=page id=p-groups>
<section><h2>Разрешённые группы <span class=sub>вызов на группу вне списка никуда не пойдёт</span></h2>
<div class=scroll><table id=t-groups></table></div>
<div class=body><form id=f-group>
<input name=tg placeholder="TG" size=8 required inputmode=numeric>
<input name=name placeholder="название" size=18>
<select name=slot><option value=1>TS1</option><option value=2 selected>TS2</option></select>
<button>Добавить</button>
</form></div>
</section>
</div>
<div class=page id=p-access>
<section><h2>Кому разрешён эфир <span class=sub>проверяется src — владелец рации, не хотспот</span></h2>
<div class=body>
<form id=f-subs>
<label><input type=radio name=mode value=off> выключено</label>
<label><input type=radio name=mode value=allow> белый список</label>
<label><input type=radio name=mode value=deny> чёрный список</label>
<button>Применить</button>
<div style="flex-basis:100%;height:0"></div>
<input name=list style="flex:1;min-width:280px"
placeholder="ID через пробел или запятую, можно диапазон 2501000-2501999">
</form>
<p class=note>На одном хотспоте работают разные люди: запретить конкретного абонента,
не отключая всё устройство, — обычное требование. Отказы видны в журнале.</p>
</div>
</section>
<section><h2>Незнакомые хотспоты</h2>
<div class=body>
<form id=f-unknown>
<label><input type=radio name=allow_unknown value=1> пускать по общему паролю</label>
<label><input type=radio name=allow_unknown value=0> только из списка</label>
<button>Применить</button>
</form>
</div>
</section>
</div>
<div class=page id=p-settings>
<section><h2>Параметры</h2>
<div class=body>
<form id=f-set>
<label>Общий пароль хотспотов <input name=passphrase placeholder="не менять — оставь пустым" size=22></label>
<label><input type=checkbox name=parrot_enabled> попугай (эхотест)</label>
<button>Сохранить</button>
</form>
<p class=note id=set-note></p>
</div>
</section>
<section><h2>Как подключить хотспот</h2>
<div class=body><p class=note id=howto></p></div>
</section>
</div>
</main>
<script>
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const hhmm=t=>new Date(t*1000).toLocaleTimeString('ru-RU');
const dt=t=>new Date(t*1000).toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',
hour:'2-digit',minute:'2-digit',second:'2-digit'});
function dur(s){s=Math.max(0,Math.round(s));const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),
m=Math.floor(s%3600/60);
return d?`${d} д ${h} ч`:h?`${h} ч ${m} мин`:m?`${m} мин ${s%60} с`:`${s} с`;}
const mhz=v=>v?(v/1e6).toFixed(4):'—';
const nz=v=>(v===null||v===undefined||v==='')?'—':v;
// ── маршрутизация по хэшу ────────────────────────────────────────────────────
const PAGES=['overview','lastheard','hotspots','groups','access','settings'];
function route(){
let p=(location.hash||'#/overview').replace('#/','');
if(!PAGES.includes(p)) p='overview';
PAGES.forEach(x=>$('#p-'+x).classList.toggle('on',x===p));
$$('nav a').forEach(a=>a.classList.toggle('on',a.getAttribute('href')==='#/'+p));
}
addEventListener('hashchange',route); route();
function tbl(id,head,rows,empty){
const el=$('#'+id); if(!el) return;
if(!rows.length){ el.outerHTML=`<div class=empty id=${id}>${empty}</div>`; return; }
el.outerHTML=`<table id=${id}><thead><tr>${head.map(h=>`<th>${h}</th>`).join('')}</tr></thead>`+
`<tbody>${rows.join('')}</tbody></table>`;
}
async function post(url,body){
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)});
const j=await r.json().catch(()=>({}));
if(!r.ok){alert(j.error||'ошибка');return false}
return true;
}
// ── действия ────────────────────────────────────────────────────────────────
async function kick(id){if(confirm('Разорвать сессию хотспота '+id+'?')&&await post('/api/peer/kick',{peer_id:id}))tick();}
async function delh(id){if(confirm('Удалить хотспот '+id+' из списка?')&&await post('/api/hotspots/del',{peer_id:id}))tick();}
async function toggleh(id){if(await post('/api/hotspots/toggle',{peer_id:id}))tick();}
async function delg(tg,slot){if(await post('/api/groups/del',{tg,slot}))tick();}
$('#f-hs').onsubmit=async e=>{e.preventDefault();const f=new FormData(e.target);
if(await post('/api/hotspots/add',{peer_id:f.get('peer_id'),name:f.get('name'),password:f.get('password')}))
{e.target.reset();tick();}};
$('#f-group').onsubmit=async e=>{e.preventDefault();const f=new FormData(e.target);
if(await post('/api/groups/add',{tg:f.get('tg'),name:f.get('name'),slot:f.get('slot')}))
{e.target.reset();tick();}};
$('#f-subs').onsubmit=async e=>{e.preventDefault();const f=new FormData(e.target);
if(await post('/api/subscribers',{mode:f.get('mode'),list:f.get('list')}))tick();};
$('#f-unknown').onsubmit=async e=>{e.preventDefault();const f=new FormData(e.target);
if(await post('/api/settings',{allow_unknown_hotspots:f.get('allow_unknown')==='1'}))tick();};
$('#f-set').onsubmit=async e=>{e.preventDefault();const f=new FormData(e.target);
const body={parrot_enabled:!!f.get('parrot_enabled')};
if(f.get('passphrase')) body.passphrase=f.get('passphrase');
if(await post('/api/settings',body)){e.target.passphrase.value='';tick();}};
// ── журнал живым потоком ────────────────────────────────────────────────────
const logEl=$('#log');
function addLog(e){
const bottom=logEl.scrollHeight-logEl.scrollTop-logEl.clientHeight<40;
const d=document.createElement('div');
d.className='k-'+e.kind;
d.innerHTML=`<span class=dim>${hhmm(e.t)}</span> ${esc(e.text)}`;
logEl.appendChild(d);
while(logEl.childElementCount>500) logEl.removeChild(logEl.firstChild);
if(bottom) logEl.scrollTop=logEl.scrollHeight; // не дёргать, если человек листает вверх
}
let es;
function connectLog(){
es=new EventSource('/api/events');
es.onopen =()=>{$('#livedot').classList.add('on');$('#livetxt').textContent='живой поток';};
es.onmessage=ev=>{try{addLog(JSON.parse(ev.data))}catch(_){}}
es.onerror=()=>{$('#livedot').classList.remove('on');
$('#livetxt').textContent='поток прерван, переподключаюсь…';
es.close(); setTimeout(connectLog,3000);};
}
connectLog();
// ── обновление состояния ────────────────────────────────────────────────────
async function tick(){
let d; try{ d=await (await fetch('/api/state')).json(); }
catch(_){ $('#hstat').textContent='нет связи с сервером'; return; }
const act=(d.calls||[]).filter(c=>c.active);
$('#hstat').innerHTML=`UDP ${d.port} · хотспотов ${d.peers.length} · ${dur(d.uptime)}`;
$('#s-peers').textContent=d.peers.length;
$('#s-active').textContent=act.length;
$('#s-calls').textContent=(d.calls||[]).length;
$('#s-up').textContent=dur(d.uptime);
tbl('t-active',['начало','откуда','куда','слот','кадров','хотспот'],
act.map(c=>`<tr><td>${hhmm(c.started)}</td><td>${c.src_id}</td>
<td>${c.type==='группа'?'TG '+c.dst_id:'приват '+c.dst_id}</td>
<td>TS${c.slot}</td><td>${c.frames}</td><td>${c.peer_id}</td></tr>`),
'Сейчас никто не передаёт');
tbl('t-lh',['время','откуда','куда','тип','слот','длительность','кадров','через хотспот'],
(d.calls||[]).map(c=>`<tr><td>${dt(c.started)}</td><td><b>${c.src_id}</b></td>
<td>${c.dst_id}</td><td>${c.type}</td><td>TS${c.slot}</td>
<td>${c.active?'<span class="pill ok">идёт</span>':c.duration+' с'}</td>
<td>${c.frames}</td><td>${c.peer_id}</td></tr>`),
'Передач пока не было — как только кто-то выйдет в эфир, появится здесь');
tbl('t-peers',['номер','позывной','адрес','страна','местоположение','RX МГц','TX МГц',
'мощн.','CC','слоты','координаты','высота','ПО','модель','в сети','простой','кадров',''],
d.peers.map(p=>`<tr><td><b>${p.peer_id}</b></td><td>${esc(p.callsign)}</td><td>${esc(p.addr)}</td>
<td>${esc(nz(p.country))}</td><td>${esc(nz(p.location))}</td>
<td>${mhz(p.rx_freq)}</td><td>${mhz(p.tx_freq)}</td>
<td>${nz(p.tx_power)}</td><td>${nz(p.color_code)}</td><td>${nz(p.slots)}</td>
<td>${p.lat!=null?p.lat+', '+p.lon:'—'}</td><td>${nz(p.height)}</td>
<td>${esc(nz(p.software_id))}</td><td>${esc(nz(p.package_id))}</td>
<td>${dur(p.online_for)}</td><td>${p.idle} с</td><td>${p.rx_packets}</td>
<td><button class=sec onclick="kick(${p.peer_id})">отключить</button></td></tr>`),
'Ни один хотспот не подключён');
tbl('t-hs',['номер','название','пароль','состояние',''],
(d.hotspots||[]).map(h=>`<tr><td>${h.peer_id}</td><td>${esc(h.name)}</td>
<td>${h.password?'свой':'общий'}</td>
<td>${h.enabled!==false?'<span class="pill ok">разрешён</span>':'<span class="pill off">отключён</span>'}</td>
<td><button class=sec onclick="toggleh(${h.peer_id})">${h.enabled!==false?'отключить':'включить'}</button>
<button class=sec onclick="delh(${h.peer_id})">удалить</button></td></tr>`),
'Список пуст');
$('#hs-note').textContent = d.allow_unknown
? 'Сейчас пускается любой хотспот, знающий общий пароль. Записи нужны, чтобы задать отдельный пароль или отключить конкретное устройство.'
: 'Режим «только из списка»: незарегистрированные хотспоты не пускаются.';
tbl('t-groups',['TG','название','слот',''],
(d.groups||[]).map(g=>`<tr><td><b>${g.tg}</b></td><td>${esc(g.name)}</td><td>TS${g.slot}</td>
<td><button class=sec onclick="delg(${g.tg},${g.slot})">удалить</button></td></tr>`),
'Групп нет — групповые вызовы никуда не пойдут');
const sub=d.subscribers||{mode:'off',list:[]};
const r=document.querySelector(`#f-subs input[value="${sub.mode}"]`); if(r) r.checked=true;
const li=$('#f-subs').list; if(document.activeElement!==li) li.value=(sub.list||[]).join(' ');
const ru=document.querySelector(`#f-unknown input[value="${d.allow_unknown?1:0}"]`); if(ru) ru.checked=true;
const pc=$('#f-set').parrot_enabled; if(document.activeElement!==pc) pc.checked=!!(d.parrot||{}).enabled;
$('#set-note').textContent='Пароль хотспотов применяется при следующем входе устройства. '
+'Попугай отвечает на TG '+((d.parrot||{}).tg||9990)+'.';
$('#howto').textContent='В Pi-Star: Configuration → DMR Master. Адрес — этот сервер, порт UDP '
+d.port+', пароль — общий либо заданный для конкретного хотспота.';
}
tick(); setInterval(tick,3000);
</script>
</html>"""