Files
rpi-tor-snowflake-panel/web/index.php
2025-11-15 09:07:04 +01:00

436 lines
16 KiB
PHP

<?php
require __DIR__ . '/lib/app.php';
auth_require();
$act = $_POST['act'] ?? '';
if ($act === 'start') @exec('sudo /usr/bin/systemctl start snowflake-proxy 2>/dev/null');
if ($act === 'stop') @exec('sudo /usr/bin/systemctl stop snowflake-proxy 2>/dev/null');
if ($act === 'restart')@exec('sudo /usr/bin/systemctl restart snowflake-proxy 2>/dev/null');
$active = trim((string)@shell_exec('systemctl is-active snowflake-proxy 2>/dev/null')) === 'active';
$enabled = trim((string)@shell_exec('systemctl is-enabled snowflake-proxy 2>/dev/null')) === 'enabled';
$pid = (int)trim((string)@shell_exec('systemctl show -p MainPID --value snowflake-proxy 2>/dev/null'));
$ver = trim((string)@shell_exec('snowflake-proxy -version 2>/dev/null'));
if ($ver === '') $ver = trim((string)@shell_exec("dpkg-query -W -f='\${Version}\n' snowflake-proxy 2>/dev/null"));
$flags = '';
$drop = '/etc/systemd/system/snowflake-proxy.service.d/override.conf';
if (is_file($drop)) {
$txt = (string)@file_get_contents($drop);
if (preg_match('/^ExecStart=.*snowflake-proxy\s+(.*)$/m', $txt, $m)) $flags = trim($m[1]);
}
$flags_display = $flags !== '' ? $flags : 'defaults';
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SnowPanel</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/panel.css" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.svg">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
<nav class="navbar navbar-dark" style="background:linear-gradient(90deg,#0d6efd,#4dabf7);">
<div class="container-fluid">
<span class="navbar-brand fw-bold">
<img src="/favicon.svg" alt="">
SnowPanel
</span>
<div class="ms-auto d-flex align-items-center gap-2">
<button id="btnTheme" class="btn btn-sm btn-outline-light">Theme</button>
<a class="btn btn-sm btn-outline-light" href="/logout.php">Logout</a>
</div>
</div>
</nav>
<div class="container py-4">
<div class="row g-3">
<div class="col-md-12">
<div class="card p-3">
<div class="h5 mb-0">Service</div>
<div class="mb-1">Status: <?= $active ? '<span class="text-success">active</span>' : '<span class="text-danger">inactive</span>' ?></div>
<div class="mb-1">Enabled: <?= $enabled ? 'yes' : 'no' ?></div>
<div class="mb-1">PID: <span class="mono"><?= $pid ?: '—' ?></span></div>
<div class="mb-1">Version: <span class="mono"><?= htmlspecialchars($ver ?: '—') ?></span></div>
<div class="mb-1">Flags: <code><?= htmlspecialchars($flags_display) ?></code></div>
<form method="post" class="d-flex gap-2 mt-3">
<button name="act" value="start" class="btn btn-success btn-sm" type="submit">Start</button>
<button name="act" value="restart" class="btn btn-warning btn-sm" type="submit">Restart</button>
<button name="act" value="stop" class="btn btn-danger btn-sm" type="submit">Stop</button>
</form>
</div>
</div>
<div class="col-12">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div class="h5 mb-0">Traffic (last 48 hours)</div>
</div>
<div id="trafficWrap"><canvas id="trafficChart"></canvas></div>
</div>
</div>
<div class="col-12">
<div class="row g-3 equal-row">
<div class="col-md-6">
<div class="card p-3">
<div class="small">RX (total)</div>
<div class="h4 mono" id="rxTotal">—</div>
</div>
</div>
<div class="col-md-6">
<div class="card p-3">
<div class="small">TX (total)</div>
<div class="h4 mono" id="txTotal">—</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div class="h5 mb-0">Limits & Usage</div>
<div class="d-flex align-items-center gap-2">
<span id="capBadge" class="badge bg-secondary">—</span>
<button id="btnEditLimits" class="btn btn-sm btn-outline-light">Edit</button>
</div>
</div>
<div id="limitsView" class="mt-2">
<div class="row g-3">
<div class="col-md-4">
<div class="small">Monthly cap</div>
<div class="mono" id="v_cap">—</div>
</div>
<div class="col-md-4">
<div class="small">Reset day</div>
<div class="mono" id="v_reset">—</div>
</div>
<div class="col-md-4">
<div class="small">Throughput limit</div>
<div class="mono" id="v_rate">—</div>
</div>
</div>
<div class="mt-3">
<div class="small mb-1">This period usage</div>
<div class="progress" role="progressbar" aria-label="Monthly cap" style="height: 14px;">
<div id="capBar" class="progress-bar" style="width:0%"></div>
</div>
<div class="d-flex justify-content-between small mt-1">
<div class="mono" id="usageLeft">—</div>
<div class="mono text-muted" id="periodLbl">—</div>
</div>
<div class="small mt-2">Current rate: <span class="mono" id="curRate">—</span></div>
</div>
</div>
<form id="limitsForm" class="mt-3" style="display:none;">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="cap_gb">Monthly cap</label>
<div class="input-group">
<input id="cap_gb" class="form-control" type="number" min="0" step="1">
<span class="input-group-text">GB</span>
</div>
</div>
<div class="col-md-4">
<label class="form-label" for="cap_reset_day">Reset day</label>
<select id="cap_reset_day" class="form-select">
<?php for($d=1;$d<=28;$d++) echo "<option>$d</option>"; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="rate_mbps">Throughput limit</label>
<div class="input-group">
<input id="rate_mbps" class="form-control" type="number" min="0" step="1">
<span class="input-group-text">Mbps</span>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" id="saveLimits" type="submit">Save</button>
<button class="btn btn-secondary" id="cancelLimits" type="button">Cancel</button>
<span id="saveLimitsMsg" class="small ms-2"></span>
</div>
</form>
</div>
</div>
<div class="col-12">
<div class="card p-3">
<div class="d-flex align-items-center justify-content-between">
<div class="h5 mb-0">Logs</div>
<div class="d-flex align-items-center gap-2">
<select id="torlogLevel" class="form-select form-select-sm" style="width:auto;">
<option value="info" selected>info</option>
<option value="notice">notice</option>
<option value="warning">warning</option>
<option value="err">err</option>
<option value="debug">debug</option>
</select>
<select id="torlogLines" class="form-select form-select-sm" style="width:auto;">
<option value="200">Last 200</option>
<option value="500" selected>Last 500</option>
<option value="1000">Last 1000</option>
<option value="2000">Last 2000</option>
</select>
<button id="btnRefresh" class="btn btn-sm btn-outline-light">Refresh</button>
</div>
</div>
<pre id="logpre" class="bg-dark text-light p-2 rounded mt-2" style="height:340px; overflow:auto; font-size:.85rem;">Loading…</pre>
</div>
</div>
</div>
</div>
<script>
const $ = (id)=>document.getElementById(id);
const el = {
btnTheme: $('btnTheme'),
logpre: $('logpre'),
lines: $('torlogLines'),
lvl: $('torlogLevel'),
btnRefresh: $('btnRefresh'),
trafficCanvas: $('trafficChart'),
rxTotal: $('rxTotal'),
txTotal: $('txTotal'),
limitsView: $('limitsView'),
limitsForm: $('limitsForm'),
btnEditLimits: $('btnEditLimits'),
saveLimits: $('saveLimits'),
cancelLimits: $('cancelLimits'),
saveLimitsMsg: $('saveLimitsMsg'),
v_cap: $('v_cap'),
v_reset: $('v_reset'),
v_rate: $('v_rate'),
capBadge: $('capBadge'),
capBar: $('capBar'),
usageLeft: $('usageLeft'),
periodLbl: $('periodLbl'),
curRate: $('curRate'),
cap_gb: $('cap_gb'),
cap_reset_day: $('cap_reset_day'),
rate_mbps: $('rate_mbps'),
};
let chart = null;
const THEME_KEY='snowpanel:theme';
const mql=window.matchMedia('(prefers-color-scheme: dark)');
function preferredTheme(){
const s=localStorage.getItem(THEME_KEY);
return (s==='dark'||s==='light') ? s : (mql.matches ? 'dark' : 'light');
}
function getThemeColors(){
const cs=getComputedStyle(document.documentElement);
return {
text: cs.getPropertyValue('--text').trim() || '#e7ecf4',
grid: cs.getPropertyValue('--grid').trim() || 'rgba(231,236,244,.15)'
};
}
function setBtnLabel(t){
if(el.btnTheme) el.btnTheme.textContent = (t==='dark') ? '🌞 Light' : '🌙 Dark';
}
function applyTheme(t){
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem(THEME_KEY, t);
setBtnLabel(t);
if(chart){
const c=getThemeColors();
chart.options.scales.x.ticks.color=c.text;
chart.options.scales.y.ticks.color=c.text;
chart.options.scales.x.grid.color=c.grid;
chart.options.scales.y.grid.color=c.grid;
if(chart.options.plugins?.legend?.labels) chart.options.plugins.legend.labels.color=c.text;
chart.update('none');
}
}
mql.addEventListener('change', e=>{
if(!localStorage.getItem(THEME_KEY)) applyTheme(e.matches?'dark':'light');
});
el.btnTheme?.addEventListener('click', ()=>{
const next=(document.documentElement.getAttribute('data-theme')==='dark')?'light':'dark';
applyTheme(next);
});
applyTheme(preferredTheme());
async function fetchLog(n,l){
const r=await fetch('/api/snow_log.php?n='+encodeURIComponent(n)+'&level='+encodeURIComponent(l),{cache:'no-store'});
if(!r.ok) return {ok:false,lines:[]};
return r.json();
}
async function loadInlineLogs(){
const n=el.lines?.value||'500';
const l=el.lvl?.value||'info';
const j=await fetchLog(n,l);
if(j.ok&&j.lines){ el.logpre.textContent=j.lines.join('\n'); el.logpre.scrollTop=el.logpre.scrollHeight; }
else el.logpre.textContent='(no logs)';
}
el.btnRefresh?.addEventListener('click', loadInlineLogs);
el.lines?.addEventListener('change', loadInlineLogs);
el.lvl?.addEventListener('change', loadInlineLogs);
loadInlineLogs();
setInterval(loadInlineLogs,60000);
function fmtBytes(n){
if(n<1024) return n+' B';
const u=['KB','MB','GB','TB']; let i=-1;
do{ n/=1024; i++; }while(n>=1024&&i<u.length-1);
return n.toFixed(2)+' '+u[i];
}
function setChartData(labels, rx, tx){
if(!el.trafficCanvas) return;
const ctx=el.trafficCanvas.getContext('2d');
const c=getThemeColors();
if(!chart){
chart=new Chart(ctx,{
type:'line',
data:{labels,datasets:[
{label:'RX/min',data:rx,tension:.3,pointRadius:2,borderWidth:2,borderColor:'#22c55e'},
{label:'TX/min',data:tx,tension:.3,pointRadius:2,borderWidth:2,borderColor:'#a78bfa'}
]},
options:{
responsive:true,
maintainAspectRatio:false,
animation:false,
plugins:{legend:{labels:{color:c.text}}},
scales:{
x:{ticks:{color:c.text},grid:{color:c.grid}},
y:{ticks:{color:c.text,callback:(v)=>fmtBytes(v)},grid:{color:c.grid}}
}
}
});
}else{
chart.data.labels=labels;
chart.data.datasets[0].data=rx;
chart.data.datasets[1].data=tx;
chart.update('none');
}
}
async function refreshChartAndTotals(){
const r=await fetch('/api/stats.php',{cache:'no-store'});
if(!r.ok) return;
const s=await r.json();
const data=s.data||[];
let labels=[],rx=[],tx=[], sumR=0, sumW=0;
for(let i=1;i<data.length;i++){
const dt=data[i].t-data[i-1].t;
if(dt<=0||dt>3600) continue;
const dr=Math.max(0,data[i].read-data[i-1].read);
const dw=Math.max(0,data[i].written-data[i-1].written);
labels.push(new Date(data[i].t*1000).toLocaleTimeString());
rx.push(dr); tx.push(dw);
sumR+=dr; sumW+=dw;
}
setChartData(labels,rx,tx);
if(el.rxTotal) el.rxTotal.textContent = sumR ? fmtBytes(sumR) : '—';
if(el.txTotal) el.txTotal.textContent = sumW ? fmtBytes(sumW) : '—';
}
refreshChartAndTotals();
setInterval(refreshChartAndTotals,60000);
function setLimitEditMode(on){
if(!el.limitsForm || !el.limitsView) return;
el.limitsForm.style.display = on ? 'block' : 'none';
el.limitsView.style.display = on ? 'none' : 'block';
if (el.btnEditLimits) el.btnEditLimits.style.display = on ? 'none' : 'inline-block';
}
async function loadLimits(){
const r = await fetch('/api/limits_get.php',{cache:'no-store'});
if(!r.ok) return;
const j = await r.json();
if(!j.ok) return;
const cap = j.cap_gb>0 ? (j.cap_gb+' GB') : 'Unlimited';
const rate= j.rate_mbps>0 ? (j.rate_mbps+' Mbps') : 'Unlimited';
if(el.v_cap) el.v_cap.textContent = cap;
if(el.v_reset) el.v_reset.textContent = j.cap_reset_day || '—';
if(el.v_rate) el.v_rate.textContent = rate;
const u = j.usage || {};
const total = u.total || 0;
let cap_bytes = (u.cap_bytes && u.cap_bytes > 0)
? u.cap_bytes
: ((j.cap_gb && j.cap_gb > 0) ? (j.cap_gb * 1073741824) : 0);
let pct = 0;
if (cap_bytes > 0) {
pct = Math.round((total * 100) / cap_bytes);
if (pct === 0 && total > 0) pct = 1;
if (pct > 100) pct = 100;
}
if (el.capBar) {
el.capBar.style.width = (cap_bytes > 0 ? pct : 0) + '%';
el.capBar.className = 'progress-bar ' + ((cap_bytes > 0 && pct >= 90) ? 'bg-danger' : (pct >= 70 ? 'bg-warning' : ''));
el.capBar.textContent = cap_bytes > 0 ? (pct + '%') : '—';
}
if(el.usageLeft){
if(cap_bytes>0){
const left = Math.max(0, cap_bytes-total);
el.usageLeft.textContent = fmtBytes(total) + ' / ' + fmtBytes(cap_bytes) + ' (left ' + fmtBytes(left) + ')';
} else {
el.usageLeft.textContent = fmtBytes(total) + ' used';
}
}
if(el.periodLbl){
el.periodLbl.textContent = u.period_label || '—';
}
if(el.curRate){
const cr = j.current_rate_mbps || 0;
el.curRate.textContent = cr.toFixed(2) + ' Mbps';
}
if (el.capBadge) {
if (cap_bytes > 0 && total >= cap_bytes) {
el.capBadge.textContent = 'Cap reached';
el.capBadge.className = 'badge bg-danger';
} else {
el.capBadge.textContent = 'Active';
el.capBadge.className = 'badge bg-success';
}
}
if(el.cap_gb) el.cap_gb.value = j.cap_gb ?? 0;
if(el.cap_reset_day) el.cap_reset_day.value = j.cap_reset_day ?? 1;
if(el.rate_mbps) el.rate_mbps.value = j.rate_mbps ?? 0;
}
el.btnEditLimits?.addEventListener('click', ()=> setLimitEditMode(true));
el.cancelLimits?.addEventListener('click', ()=> setLimitEditMode(false));
el.limitsForm?.addEventListener('submit', async (e)=>{
e.preventDefault();
if (el.saveLimitsMsg) el.saveLimitsMsg.textContent = 'Saving...';
const body = {
cap_gb: parseInt(el.cap_gb?.value||'0'),
cap_reset_day: parseInt(el.cap_reset_day?.value||'1'),
rate_mbps: parseInt(el.rate_mbps?.value||'0')
};
const r = await fetch('/api/limits_set.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
const j = await r.json();
if (el.saveLimitsMsg) el.saveLimitsMsg.textContent = j.ok ? 'Saved' : ('Error: '+(j.error||'unknown'));
if (j.ok){
await loadLimits();
setLimitEditMode(false);
}
});
loadLimits();
setInterval(loadLimits, 15000);
</script>
</body>
</html>