436 lines
16 KiB
PHP
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>
|