Панель: • шесть страниц с маршрутизацией по хэшу: Обзор, 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
322 строки
19 KiB
Python
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=>({'&':'&','<':'<','>':'>','"':'"'}[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>"""
|