diff --git a/admin/index.html b/admin/index.html
index 1d2ee88..48cb837 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -975,6 +975,12 @@ function fmtBytes(n) {
const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
return (m/1024).toFixed(1)+" GiB";
}
+function localDateKey(d = new Date()) {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
function fmtDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
@@ -1695,14 +1701,17 @@ async function loadVnstat() {
renderVnstatRows(vnstatDailyBody, daily, "No daily usage recorded yet.");
renderVnstatRows(vnstatMonthlyBody, monthly, "No monthly usage recorded yet.");
- const today = new Date().toISOString().slice(0,10);
- const month = today.slice(0,7);
- const todayTotal = daily.filter(r => r.period === today).reduce((sum, r) => sum + (r.total_bytes||0), 0);
- const monthTotal = monthly.filter(r => r.period === month).reduce((sum, r) => sum + (r.total_bytes||0), 0);
+ // Use the server/database periods when available. Falling back to the
+ // newest row avoids browser UTC/local-time mismatches that can make
+ // "Today total" show 0 while the daily table has data.
+ const today = data.today_period || daily[0]?.period || localDateKey();
+ const month = data.month_period || today.slice(0,7);
+ const todayTotal = data.today_total_bytes ?? daily.filter(r => r.period === today).reduce((sum, r) => sum + (r.total_bytes||0), 0);
+ const monthTotal = data.month_total_bytes ?? monthly.filter(r => r.period === month).reduce((sum, r) => sum + (r.total_bytes||0), 0);
const ifaces = new Set([...daily, ...monthly].map(r => r.iface).filter(Boolean));
vnTodayTotal.textContent = fmtBytes(todayTotal);
vnMonthTotal.textContent = fmtBytes(monthTotal);
- vnIfaceCount.textContent = String(ifaces.size || 0);
+ vnIfaceCount.textContent = String(data.interface_count ?? ifaces.size ?? 0);
vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned.";
} catch (e) {
if (e.message === "auth") doAuthError();
diff --git a/admin_script.js b/admin_script.js
new file mode 100644
index 0000000..64ce1ad
--- /dev/null
+++ b/admin_script.js
@@ -0,0 +1,1553 @@
+// ─── State ───────────────────────────────────────────────────────────────────
+let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
+let currentRole = "";
+let currentUser = "";
+let statsTimer = null, usersTimer = null, xrayTimer = null;
+let formCollapsed = true;
+let tlsForwardersState = [];
+let editingXrayClientId = null;
+let wzInbounds = [];
+
+// ─── DOM refs ─────────────────────────────────────────────────────────────────
+const loginOverlay = document.getElementById("loginOverlay");
+const loginUser = document.getElementById("loginUser");
+const loginPass = document.getElementById("loginPass");
+const loginBtn = document.getElementById("loginBtn");
+const loginErr = document.getElementById("loginErr");
+const mainApp = document.getElementById("mainApp");
+const meUsername = document.getElementById("meUsername");
+const roleChip = document.getElementById("roleChip");
+const logoutBtn = document.getElementById("logoutBtn");
+
+// Users
+const usersBody = document.getElementById("usersBody");
+const userCountChip = document.getElementById("userCountChip");
+const userStatus = document.getElementById("userStatus");
+const lastReload = document.getElementById("lastReload");
+const ownerColHead = document.getElementById("ownerColHead");
+const resellerInfoCard = document.getElementById("resellerInfoCard");
+const rUsedMax = document.getElementById("rUsedMax");
+const rExpiry = document.getElementById("rExpiry");
+const rStatus = document.getElementById("rStatus");
+
+// User form
+const userForm = document.getElementById("userForm");
+const userFormWrap = document.getElementById("userFormWrap");
+const toggleFormBtn = document.getElementById("toggleFormBtn");
+const cancelUserBtn = document.getElementById("cancelUserBtn");
+const newUserBtn = document.getElementById("newUserBtn");
+const saveUserBtn = document.getElementById("saveUserBtn");
+const fUsername = document.getElementById("fUsername");
+const fPassword = document.getElementById("fPassword");
+const fTotpSecret = document.getElementById("fTotpSecret");
+const fTotpPeriod = document.getElementById("fTotpPeriod");
+const fTotpWindow = document.getElementById("fTotpWindow");
+const fTotpDigits = document.getElementById("fTotpDigits");
+const fAllowStatic = document.getElementById("fAllowStatic");
+const fMaxConn = document.getElementById("fMaxConn");
+const fExpires = document.getElementById("fExpires");
+const fUp = document.getElementById("fUp");
+const fDown = document.getElementById("fDown");
+
+// Xray
+const xrayChip = document.getElementById("xrayChip");
+const xRunning = document.getElementById("xRunning");
+const xPID = document.getElementById("xPID");
+const xUptime = document.getElementById("xUptime");
+const xStatus = document.getElementById("xStatus");
+const xCfgEditor = document.getElementById("xCfgEditor");
+const xCfgStatus = document.getElementById("xCfgStatus");
+const xLogsBox = document.getElementById("xLogsBox");
+const inboundsContainer = document.getElementById("inboundsContainer");
+
+// Resellers
+const resellersBody = document.getElementById("resellersBody");
+const resellerCountChip = document.getElementById("resellerCountChip");
+const resellerStatus = document.getElementById("resellerStatus");
+const resellerFormTitle = document.getElementById("resellerFormTitle");
+const resellerForm = document.getElementById("resellerForm");
+const rUsername = document.getElementById("rUsername");
+const rPassword = document.getElementById("rPassword");
+const rMaxUsers = document.getElementById("rMaxUsers");
+const rExpires = document.getElementById("rExpires");
+const rActive = document.getElementById("rActive");
+
+// Stats
+const cpuVal = document.getElementById("cpuVal");
+const cpuBar = document.getElementById("cpuBar");
+const memVal = document.getElementById("memVal");
+const memBar = document.getElementById("memBar");
+const memDetail = document.getElementById("memDetail");
+const ifaceBody = document.getElementById("ifaceBody");
+const ifaceSummary = document.getElementById("ifaceSummary");
+const statsUpdated = document.getElementById("statsUpdated");
+const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
+
+// VnStat
+const vnstatDailyBody = document.getElementById("vnstatDailyBody");
+const vnstatMonthlyBody = document.getElementById("vnstatMonthlyBody");
+const vnstatStatus = document.getElementById("vnstatStatus");
+const vnTodayTotal = document.getElementById("vnTodayTotal");
+const vnMonthTotal = document.getElementById("vnMonthTotal");
+const vnIfaceCount = document.getElementById("vnIfaceCount");
+const reloadVnstatBtn = document.getElementById("reloadVnstatBtn");
+const resetVnstatBtn = document.getElementById("resetVnstatBtn");
+
+// ─── API helper ───────────────────────────────────────────────────────────────
+async function api(path, opts = {}) {
+ const o = Object.assign({ headers: {} }, opts);
+ o.headers = Object.assign({}, o.headers, {
+ "Content-Type": "application/json",
+ "X-Session-Token": sessionToken,
+ });
+ const res = await fetch(path, o);
+ if (res.status === 401 || res.status === 403) throw new Error("auth");
+ return res;
+}
+
+// ─── Formatters ──────────────────────────────────────────────────────────────
+const fmtPct = n => (n == null || isNaN(n)) ? "--%" : n.toFixed(1)+"%";
+const fmtMbps = n => (n == null || isNaN(n)) ? "--" : n.toFixed(2);
+function fmtBytes(n) {
+ if (!Number.isFinite(n)) return "--";
+ if (n<1024) return n+" B";
+ const k=n/1024; if(k<1024) return k.toFixed(1)+" KiB";
+ const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
+ return (m/1024).toFixed(1)+" GiB";
+}
+function localDateKey(d = new Date()) {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
+function fmtDate(iso) {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return "—";
+ return d.toLocaleDateString()+" "+d.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
+}
+function isoFromLocal(v) {
+ if (!v) return "";
+ const d = new Date(v);
+ return isNaN(d.getTime()) ? "" : d.toISOString();
+}
+function localFromISO(iso) {
+ if (!iso) return "";
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return "";
+ const pad = n => String(n).padStart(2,"0");
+ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+function genBase32(len=20) {
+ const alpha="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+ const bytes=new Uint8Array(len);
+ crypto.getRandomValues(bytes);
+ let bits=0,val=0,out="";
+ for(const b of bytes){val=(val<<8)|b;bits+=8;while(bits>=5){out+=alpha[(val>>>(bits-5))&31];bits-=5;}}
+ if(bits>0) out+=alpha[(val<<(5-bits))&31];
+ return out;
+}
+function genUUID() {
+ return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
+ (c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16));
+}
+
+// ─── Tab switching ────────────────────────────────────────────────────────────
+document.querySelectorAll(".tab-btn").forEach(btn => {
+ btn.addEventListener("click", () => {
+ document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
+ document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
+ btn.classList.add("active");
+ document.getElementById("tab-" + btn.dataset.tab).classList.add("active");
+ });
+});
+
+// ─── Login / Logout ───────────────────────────────────────────────────────────
+loginBtn.addEventListener("click", doLogin);
+loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); });
+logoutBtn.addEventListener("click", async () => {
+ try { await api("/api/auth/logout", { method: "POST" }); } catch {}
+ sessionToken = "";
+ localStorage.removeItem("SESSION_TOKEN");
+ clearTimers();
+ mainApp.classList.add("hidden");
+ loginOverlay.classList.remove("hidden");
+ loginErr.textContent = "";
+ loginUser.value = loginPass.value = "";
+});
+
+async function doLogin() {
+ loginErr.textContent = "";
+ loginBtn.disabled = true;
+ try {
+ const res = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: {"Content-Type":"application/json"},
+ body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value }),
+ });
+ if (!res.ok) {
+ loginErr.textContent = res.status === 401 ? "Invalid credentials." :
+ res.status === 403 ? "Account suspended or expired." :
+ "Login failed.";
+ return;
+ }
+ const data = await res.json();
+ sessionToken = data.token;
+ currentRole = data.role;
+ currentUser = data.username;
+ localStorage.setItem("SESSION_TOKEN", sessionToken);
+ loginOverlay.classList.add("hidden");
+ mainApp.classList.remove("hidden");
+ initAfterLogin();
+ } catch (e) {
+ loginErr.textContent = "Network error.";
+ } finally {
+ loginBtn.disabled = false;
+ }
+}
+
+// ─── Init after login ─────────────────────────────────────────────────────────
+function clearTimers() {
+ [statsTimer, usersTimer, xrayTimer].forEach(t => t && clearInterval(t));
+ statsTimer = usersTimer = xrayTimer = null;
+}
+
+function initAfterLogin() {
+ meUsername.textContent = currentUser;
+ roleChip.innerHTML = currentRole === "superadmin"
+ ? `superadmin`
+ : `reseller`;
+
+ // Show/hide superadmin-only elements
+ document.querySelectorAll(".superadmin-only").forEach(el => {
+ el.classList.toggle("hidden", currentRole !== "superadmin");
+ });
+
+ // Reset to SSH tab
+ document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
+ document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
+ document.querySelector("[data-tab='ssh']").classList.add("active");
+ document.getElementById("tab-ssh").classList.add("active");
+
+ if (currentRole === "superadmin") {
+ loadStats();
+ statsTimer = setInterval(loadStats, 2000);
+ xrayTimer = setInterval(loadXrayStatus, 5000);
+ } else {
+ resellerInfoCard.classList.remove("hidden");
+ loadMe();
+ }
+
+ loadUsers();
+ usersTimer = setInterval(() => loadUsersSilent(), 3000);
+}
+
+// ─── Me (reseller info) ───────────────────────────────────────────────────────
+async function loadMe() {
+ try {
+ const res = await api("/api/auth/me");
+ const d = await res.json();
+ rUsedMax.textContent = (d.used_users ?? "--") + " / " + (d.max_users || "∞");
+ rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : "No limit";
+ rStatus.textContent = d.is_active ? "Active" : "Suspended";
+ rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
+ } catch {}
+}
+
+// ─── SSH Users ────────────────────────────────────────────────────────────────
+document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
+newUserBtn.addEventListener("click", () => {
+ setFormCollapsed(false);
+ userForm.reset();
+ fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
+ userStatus.textContent = "New user.";
+ fUsername.focus();
+});
+cancelUserBtn.addEventListener("click", () => setFormCollapsed(true));
+toggleFormBtn.addEventListener("click", () => setFormCollapsed(!formCollapsed));
+document.getElementById("genTotpBtn").addEventListener("click", () => {
+ fTotpSecret.value = genBase32();
+ if (!fTotpPeriod.value) fTotpPeriod.value = 60;
+ if (!fTotpWindow.value) fTotpWindow.value = 1;
+ if (!fTotpDigits.value) fTotpDigits.value = 6;
+ userStatus.textContent = "TOTP secret generated.";
+});
+document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; });
+
+function setFormCollapsed(v) {
+ formCollapsed = v;
+ userFormWrap.classList.toggle("collapsed", v);
+ toggleFormBtn.textContent = v ? "Show form" : "Hide form";
+}
+
+async function loadUsers() {
+ userStatus.textContent = "Loading…";
+ try {
+ const res = await api("/api/users");
+ const data = await res.json();
+ renderUsers(data || []);
+ userStatus.textContent = "Loaded.";
+ lastReload.textContent = "Last reload: " + new Date().toLocaleTimeString();
+ } catch (e) {
+ if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = "Error loading users."; }
+ }
+}
+async function loadUsersSilent() {
+ try {
+ const res = await api("/api/users");
+ const data = await res.json();
+ renderUsers(data || []);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ }
+}
+
+function renderUsers(users) {
+ const isSA = currentRole === "superadmin";
+ userCountChip.textContent = users.length;
+ if (isSA) ownerColHead.classList.remove("hidden");
+ usersBody.innerHTML = "";
+ let online = 0;
+ users.forEach(u => {
+ const on = (u.active_conns || 0) > 0;
+ if (on) online++;
+ const tr = document.createElement("tr");
+ const cells = [
+ u.username,
+ on ? 'online' : 'idle',
+ u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password",
+ u.active_conns ?? 0,
+ u.max_connections || 0,
+ u.limit_mbps_up || 0,
+ u.limit_mbps_down || 0,
+ u.expires_at ? fmtDate(u.expires_at) : "—",
+ ];
+ if (isSA) cells.push(u.owner_username || "—");
+ cells.forEach((c, i) => {
+ const td = document.createElement("td");
+ if (i === 1) td.innerHTML = c; else td.textContent = c;
+ tr.appendChild(td);
+ });
+ const tdA = document.createElement("td");
+ const editBtn = Object.assign(document.createElement("button"), {
+ className:"btn btn-ghost btn-sm", textContent:"Edit",
+ onclick: () => fillUserForm(u),
+ });
+ const delBtn = Object.assign(document.createElement("button"), {
+ className:"btn btn-danger btn-sm", textContent:"Del",
+ style: "margin-left:4px;",
+ onclick: () => deleteUser(u.username),
+ });
+ tdA.append(editBtn, delBtn);
+ tr.appendChild(tdA);
+ usersBody.appendChild(tr);
+ });
+ userCountChip.textContent = `${users.length} (${online} online)`;
+}
+
+function fillUserForm(u) {
+ setFormCollapsed(false);
+ fUsername.value = u.username || "";
+ fPassword.value = "";
+ fTotpSecret.value = u.totp_secret || "";
+ fTotpPeriod.value = u.totp_period || 60;
+ fTotpWindow.value = u.totp_window ?? 1;
+ fTotpDigits.value = u.totp_digits || 6;
+ fAllowStatic.checked = !!u.allow_static_password;
+ fMaxConn.value = u.max_connections || "";
+ fUp.value = u.limit_mbps_up || "";
+ fDown.value = u.limit_mbps_down || "";
+ fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
+ userStatus.textContent = `Editing ${u.username}`;
+}
+
+userForm.addEventListener("submit", async e => {
+ e.preventDefault();
+ saveUserBtn.disabled = true;
+ userStatus.textContent = "Saving…";
+ const payload = {
+ username: fUsername.value.trim(),
+ password: fPassword.value || undefined,
+ totp_secret: fTotpSecret.value.trim(),
+ totp_period: parseInt(fTotpPeriod.value||"60",10),
+ totp_window: parseInt(fTotpWindow.value||"1",10),
+ totp_digits: parseInt(fTotpDigits.value||"6",10),
+ allow_static_password: !!fAllowStatic.checked,
+ max_connections: parseInt(fMaxConn.value||"0",10),
+ expires_at: isoFromLocal(fExpires.value),
+ limit_mbps_up: parseInt(fUp.value||"0",10),
+ limit_mbps_down: parseInt(fDown.value||"0",10),
+ };
+ try {
+ const res = await api("/api/users/create", { method:"POST", body: JSON.stringify(payload) });
+ if (!res.ok) throw new Error(await res.text());
+ userStatus.textContent = "Saved.";
+ fPassword.value = "";
+ loadUsers();
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else userStatus.textContent = "Error: " + e.message;
+ } finally {
+ saveUserBtn.disabled = false;
+ }
+});
+
+async function deleteUser(username) {
+ if (!confirm(`Delete user "${username}"?`)) return;
+ userStatus.textContent = `Deleting ${username}…`;
+ try {
+ const res = await api(`/api/users/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
+ if (!res.ok && res.status !== 204) throw new Error("delete failed");
+ userStatus.textContent = "Deleted.";
+ loadUsers();
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else userStatus.textContent = "Error deleting.";
+ }
+}
+
+// ─── Xray ─────────────────────────────────────────────────────────────────────
+document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("start"));
+document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
+document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
+document.getElementById("xRefreshBtn").addEventListener("click", loadXrayStatus);
+document.getElementById("xLoadInboundsBtn").addEventListener("click", loadInbounds);
+document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
+document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
+document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
+
+document.querySelector("[data-tab='xray']")?.addEventListener("click", () => {
+ loadXrayStatus(); loadInbounds(); loadWizardFromConfig();
+});
+
+async function loadXrayStatus() {
+ try {
+ const res = await api("/api/xray/status");
+ const s = await res.json();
+ const run = !!s.running;
+ xrayChip.textContent = run ? "running" : (s.enabled ? "stopped" : "disabled");
+ xrayChip.className = "chip " + (run ? "green" : "red");
+ xRunning.textContent = run ? "Running" : "Stopped";
+ xRunning.style.color = run ? "var(--success)" : "var(--danger)";
+ xPID.textContent = s.pid || "--";
+ xUptime.textContent = s.uptime || "--";
+ if (s.error) xStatus.textContent = "Error: " + s.error;
+ } catch (e) { if (e.message==="auth") doAuthError(); }
+}
+
+async function xrayCtrl(action) {
+ xStatus.textContent = action.charAt(0).toUpperCase()+action.slice(1)+"ing Xray…";
+ try {
+ const res = await api(`/api/xray/${action}`, { method:"POST" });
+ if (!res.ok) throw new Error(await res.text());
+ xStatus.textContent = "Xray "+action+" OK.";
+ setTimeout(loadXrayStatus, 700);
+ setTimeout(loadInbounds, 1200);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function loadInbounds() {
+ inboundsContainer.innerHTML = '
Loading…
';
+ try {
+ const res = await api("/api/xray/inbounds");
+ const inbounds = await res.json();
+ renderInbounds(inbounds || []);
+ } catch (e) {
+ inboundsContainer.textContent = "Error loading inbounds.";
+ if (e.message==="auth") doAuthError();
+ }
+}
+
+function renderInbounds(inbounds) {
+ if (!inbounds.length) {
+ inboundsContainer.innerHTML = 'No VLESS/VMess/Trojan inbounds found.
';
+ return;
+ }
+ inboundsContainer.innerHTML = "";
+ inbounds.forEach(ib => {
+ const section = document.createElement("div");
+ section.style = "margin-bottom:14px;";
+
+ const hdr = document.createElement("div");
+ hdr.className = "card-hdr";
+ hdr.style = "margin-bottom:6px;";
+ hdr.innerHTML = `
+
+ ${ib.protocol}
+ ${ib.tag || "untagged"}
+ :${ib.port ?? "?"}
+
+ `;
+ section.appendChild(hdr);
+
+ // Add client mini-form (hidden by default)
+ const addForm = document.createElement("div");
+ addForm.id = `add-form-${ib.tag}`;
+ addForm.className = "hidden";
+ addForm.style = "background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;";
+ addForm.innerHTML = `
+
+
+
+
+
`;
+ section.appendChild(addForm);
+
+ // Clients table
+ const tblWrap = document.createElement("div");
+ tblWrap.className = "tbl-wrap";
+ const clients = ib.clients || [];
+ if (!clients.length) {
+ tblWrap.innerHTML = 'No clients.
';
+ } else {
+ const tbl = document.createElement("table");
+ tbl.innerHTML = `| Name | UUID | Email | Expiry | Status | Max | Actions |
`;
+ const tbody = document.createElement("tbody");
+ clients.forEach(c => {
+ const exp = c.expires_at ? new Date(c.expires_at) : null;
+ const expStr = exp ? exp.toLocaleDateString() : "Unlimited";
+ const isExpired = !!c.expired;
+ const daysLeft = c.expiration_days;
+ let statusHtml;
+ if (isExpired) {
+ statusHtml = `Expired`;
+ } else if (daysLeft === -1 || !exp) {
+ statusHtml = `Active`;
+ } else {
+ statusHtml = `Active (${daysLeft}d)`;
+ }
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${c.name || "—"} |
+ ${c.id} |
+ ${c.email || "—"} |
+ ${expStr} |
+ ${statusHtml} |
+ ${c.max_conns || "∞"} | `;
+ const actTd = document.createElement("td");
+ actTd.style.whiteSpace = "nowrap";
+ const copyBtn = document.createElement("button");
+ copyBtn.className = "btn btn-ghost btn-sm";
+ copyBtn.textContent = "Copy";
+ copyBtn.onclick = () => navigator.clipboard.writeText(c.id);
+ const editBtn = document.createElement("button");
+ editBtn.className = "btn btn-warn btn-sm";
+ editBtn.style.marginLeft = "4px";
+ editBtn.textContent = "Edit";
+ editBtn.onclick = () => openEditXrayClient(ib.tag, c);
+ const delBtn = document.createElement("button");
+ delBtn.className = "btn btn-danger btn-sm";
+ delBtn.style.marginLeft = "4px";
+ delBtn.textContent = "Del";
+ delBtn.onclick = () => removeClient(ib.tag, c.id);
+ actTd.append(copyBtn, editBtn, delBtn);
+ tr.appendChild(actTd);
+ tbody.appendChild(tr);
+ });
+ tbl.appendChild(tbody);
+ tblWrap.appendChild(tbl);
+ }
+ section.appendChild(tblWrap);
+
+ const divider = document.createElement("hr");
+ divider.style = "border:none;border-top:1px solid var(--border);margin-top:10px;";
+ section.appendChild(divider);
+
+ inboundsContainer.appendChild(section);
+ });
+}
+
+function openAddClient(tag) {
+ const form = document.getElementById(`add-form-${tag}`);
+ if (form) { form.classList.remove("hidden"); }
+ const uuidField = document.getElementById(`newUUID-${tag}`);
+ if (uuidField && !uuidField.value) uuidField.value = genUUID();
+}
+
+async function addClient(tag) {
+ const uuidEl = document.getElementById(`newUUID-${tag}`);
+ const emailEl = document.getElementById(`newEmail-${tag}`);
+ const nameEl = document.getElementById(`newName-${tag}`);
+ const expiryEl = document.getElementById(`newExpiry-${tag}`);
+ const maxConnsEl = document.getElementById(`newMaxConns-${tag}`);
+ const uuid = (uuidEl?.value || "").trim();
+ const email = (emailEl?.value || "").trim();
+ const name = (nameEl?.value || "").trim();
+ const expiresAt = isoFromLocal(expiryEl?.value || "");
+ const maxConns = parseInt(maxConnsEl?.value || "0", 10) || 0;
+ if (!uuid) { xStatus.textContent = "UUID required."; return; }
+ try {
+ const res = await api("/api/xray/clients/add", {
+ method: "POST",
+ body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns }),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ xStatus.textContent = `Client ${uuid.slice(0,8)}… added. Restarting Xray…`;
+ setTimeout(loadInbounds, 1500);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function removeClient(tag, uuid) {
+ if (!confirm(`Remove client ${uuid.slice(0,8)}… from ${tag}?`)) return;
+ try {
+ const res = await api(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, { method:"DELETE" });
+ if (!res.ok && res.status !== 204) throw new Error(await res.text());
+ xStatus.textContent = "Client removed. Restarting Xray…";
+ setTimeout(loadInbounds, 1500);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function loadXrayCfg() {
+ try {
+ const res = await api("/api/xray/config");
+ if (!res.ok) throw new Error(await res.text());
+ const text = await res.text();
+ try { xCfgEditor.value = JSON.stringify(JSON.parse(text), null, 2); }
+ catch { xCfgEditor.value = text; }
+ xCfgStatus.textContent = "Config loaded.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xCfgStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function saveXrayCfg() {
+ const text = xCfgEditor.value.trim();
+ try { JSON.parse(text); } catch(e) { xCfgStatus.textContent = "Invalid JSON: "+e.message; return; }
+ xCfgStatus.textContent = "Saving…";
+ try {
+ const res = await api("/api/xray/config", { method:"POST", body: text });
+ if (!res.ok) throw new Error(await res.text());
+ xCfgStatus.textContent = "Saved. Restarting Xray…";
+ await xrayCtrl("restart");
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xCfgStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function loadXrayLogs() {
+ try {
+ const res = await api("/api/xray/logs");
+ const data = await res.json();
+ xLogsBox.textContent = (data.lines||[]).join("\n");
+ xLogsBox.scrollTop = xLogsBox.scrollHeight;
+ } catch (e) { if (e.message==="auth") doAuthError(); }
+}
+
+// ─── Resellers ────────────────────────────────────────────────────────────────
+document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers);
+document.getElementById("newResellerBtn").addEventListener("click", () => {
+ resellerFormTitle.textContent = "Create Reseller";
+ resellerForm.reset();
+ rActive.checked = true;
+ resellerStatus.textContent = "New reseller.";
+});
+document.getElementById("cancelResellerBtn").addEventListener("click", () => {
+ resellerForm.reset();
+ rActive.checked = true;
+ resellerFormTitle.textContent = "Create Reseller";
+});
+
+document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
+
+async function loadResellers() {
+ resellerStatus.textContent = "Loading…";
+ try {
+ const res = await api("/api/resellers");
+ const data = await res.json();
+ renderResellers(data || []);
+ resellerStatus.textContent = "Loaded.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else resellerStatus.textContent = "Error loading.";
+ }
+}
+
+function renderResellers(list) {
+ resellerCountChip.textContent = list.length;
+ resellersBody.innerHTML = "";
+ list.forEach(r => {
+ const expired = r.expires_at && new Date(r.expires_at) < new Date();
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${r.username} |
+ ${r.used_users} / ${r.max_users || "∞"} |
+ ${r.expires_at ? fmtDate(r.expires_at) : "—"} |
+ ${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"} |
+ | `;
+ const tdA = tr.lastElementChild;
+ const editBtn = Object.assign(document.createElement("button"),{
+ className:"btn btn-ghost btn-sm", textContent:"Edit",
+ onclick: () => fillResellerForm(r),
+ });
+ const delBtn = Object.assign(document.createElement("button"),{
+ className:"btn btn-danger btn-sm", textContent:"Del",
+ style: "margin-left:4px;",
+ onclick: () => deleteReseller(r.username),
+ });
+ tdA.append(editBtn, delBtn);
+ resellersBody.appendChild(tr);
+ });
+}
+
+function fillResellerForm(r) {
+ resellerFormTitle.textContent = `Edit: ${r.username}`;
+ rUsername.value = r.username;
+ rPassword.value = "";
+ rMaxUsers.value = r.max_users || 0;
+ rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
+ rActive.checked = r.is_active;
+ resellerStatus.textContent = `Editing ${r.username}.`;
+}
+
+resellerForm.addEventListener("submit", async e => {
+ e.preventDefault();
+ const btn = document.getElementById("saveResellerBtn");
+ btn.disabled = true;
+ resellerStatus.textContent = "Saving…";
+ const payload = {
+ username: rUsername.value.trim(),
+ password: rPassword.value || undefined,
+ max_users: parseInt(rMaxUsers.value||"0",10),
+ expires_at: isoFromLocal(rExpires.value),
+ is_active: rActive.checked,
+ };
+ try {
+ const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) });
+ if (!res.ok) throw new Error(await res.text());
+ resellerStatus.textContent = "Saved.";
+ resellerForm.reset(); rActive.checked = true;
+ resellerFormTitle.textContent = "Create Reseller";
+ loadResellers();
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else resellerStatus.textContent = "Error: "+e.message;
+ } finally { btn.disabled = false; }
+});
+
+async function deleteReseller(username) {
+ if (!confirm(`Delete reseller "${username}"? All their SSH sessions will be disconnected.`)) return;
+ resellerStatus.textContent = `Deleting ${username}…`;
+ try {
+ const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
+ if (!res.ok && res.status !== 204) throw new Error("failed");
+ resellerStatus.textContent = "Deleted.";
+ loadResellers();
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else resellerStatus.textContent = "Error deleting.";
+ }
+}
+
+// ─── Stats ────────────────────────────────────────────────────────────────────
+document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
+
+async function loadStats() {
+ try {
+ const res = await api("/api/stats");
+ const s = await res.json();
+ const cpu = s?.cpu_percent ?? 0;
+ cpuVal.textContent = fmtPct(cpu);
+ cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%";
+ const mp = s?.mem_percent ?? null;
+ memVal.textContent = mp == null ? "--%" : fmtPct(mp);
+ memBar.style.width = mp == null ? "0%" : Math.min(100, Math.max(0, mp)) + "%";
+ const mu = s?.mem_used_bytes, mt = s?.mem_total_bytes;
+ memDetail.textContent = (mu != null && mt != null) ? `${fmtBytes(mu)} / ${fmtBytes(mt)}` : "";
+ const ifaces = Array.isArray(s.interfaces) ? s.interfaces : [];
+ ifaceBody.innerHTML = "";
+ let totRx = 0, totTx = 0;
+ ifaces.forEach(it => {
+ totRx += it.rx_bytes||0; totTx += it.tx_bytes||0;
+ const tr = document.createElement("tr");
+ tr.innerHTML = `${it.name} | ${fmtMbps(it.rx_mbps)} | ${fmtMbps(it.tx_mbps)} | ${fmtBytes(it.rx_bytes)} | ${fmtBytes(it.tx_bytes)} | `;
+ ifaceBody.appendChild(tr);
+ });
+ ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
+ statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
+ } catch (e) { if (e.message==="auth") doAuthError(); }
+}
+
+resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats);
+
+async function resetInterfaceStats() {
+ if (!confirm("Clean the live Interface totals now? This does not delete VnStat daily/monthly history.")) return;
+ resetIfaceStatsBtn.disabled = true;
+ ifaceSummary.textContent = "Cleaning interface totals…";
+ try {
+ const res = await api("/api/stats/interfaces/reset", { method:"POST" });
+ if (!res.ok) throw new Error(await res.text());
+ ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days.";
+ loadStats();
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else ifaceSummary.textContent = "Error cleaning totals: " + e.message;
+ } finally {
+ resetIfaceStatsBtn.disabled = false;
+ }
+}
+
+// ─── VnStat ───────────────────────────────────────────────────────────────────
+document.querySelector("[data-tab='vnstat']")?.addEventListener("click", loadVnstat);
+reloadVnstatBtn?.addEventListener("click", loadVnstat);
+resetVnstatBtn?.addEventListener("click", resetVnstatHistory);
+
+function renderVnstatRows(body, rows, emptyLabel) {
+ body.innerHTML = "";
+ if (!rows.length) {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `${emptyLabel} | `;
+ body.appendChild(tr);
+ return;
+ }
+ rows.forEach(r => {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `${r.period || "--"} | ${r.iface || "--"} | ${fmtBytes(r.rx_bytes||0)} | ${fmtBytes(r.tx_bytes||0)} | ${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))} | `;
+ body.appendChild(tr);
+ });
+}
+
+async function loadVnstat() {
+ vnstatStatus.textContent = "Loading VnStat usage…";
+ try {
+ const res = await api("/api/vnstat?days=31&months=12");
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ const daily = Array.isArray(data.daily) ? data.daily : [];
+ const monthly = Array.isArray(data.monthly) ? data.monthly : [];
+ renderVnstatRows(vnstatDailyBody, daily, "No daily usage recorded yet.");
+ renderVnstatRows(vnstatMonthlyBody, monthly, "No monthly usage recorded yet.");
+
+ // Use the server/database periods when available. Falling back to the
+ // newest row avoids browser UTC/local-time mismatches that can make
+ // "Today total" show 0 while the daily table has data.
+ const today = data.today_period || daily[0]?.period || localDateKey();
+ const month = data.month_period || today.slice(0,7);
+ const todayTotal = data.today_total_bytes ?? daily.filter(r => r.period === today).reduce((sum, r) => sum + (r.total_bytes||0), 0);
+ const monthTotal = data.month_total_bytes ?? monthly.filter(r => r.period === month).reduce((sum, r) => sum + (r.total_bytes||0), 0);
+ const ifaces = new Set([...daily, ...monthly].map(r => r.iface).filter(Boolean));
+ vnTodayTotal.textContent = fmtBytes(todayTotal);
+ vnMonthTotal.textContent = fmtBytes(monthTotal);
+ vnIfaceCount.textContent = String(data.interface_count ?? ifaces.size ?? 0);
+ vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned.";
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else vnstatStatus.textContent = "Error loading VnStat usage: " + e.message;
+ }
+}
+
+async function resetVnstatHistory() {
+ if (!confirm("Clean all VnStat daily/monthly usage history? This does not reset the live Interface totals.")) return;
+ resetVnstatBtn.disabled = true;
+ vnstatStatus.textContent = "Cleaning VnStat history…";
+ try {
+ const res = await api("/api/vnstat/reset", { method:"POST" });
+ if (!res.ok) throw new Error(await res.text());
+ vnstatStatus.textContent = "VnStat history cleaned.";
+ loadVnstat();
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else vnstatStatus.textContent = "Error cleaning VnStat history: " + e.message;
+ } finally {
+ resetVnstatBtn.disabled = false;
+ }
+}
+
+// ─── Logs ─────────────────────────────────────────────────────────────────────
+document.querySelector("[data-tab='logs']")?.addEventListener("click", loadSystemLogs);
+document.getElementById("logSource")?.addEventListener("change", loadSystemLogs);
+document.getElementById("clearPanelLogBtn")?.addEventListener("click", clearPanelLog);
+
+async function loadSystemLogs() {
+ const box = document.getElementById("systemLogBox");
+ const st = document.getElementById("systemLogStatus");
+ const source = document.getElementById("logSource")?.value || "panel";
+ const clearBtn = document.getElementById("clearPanelLogBtn");
+ if (clearBtn) clearBtn.disabled = source !== "panel";
+ st.textContent = "Loading…";
+ try {
+ const res = await api(`/api/system/logs?source=${encodeURIComponent(source)}&lines=500`);
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ const lines = Array.isArray(data.lines) ? data.lines : [];
+ box.textContent = lines.length ? lines.join("\n") : "No log lines yet.";
+ box.scrollTop = box.scrollHeight;
+ st.textContent = `${data.source || source} logs${data.path ? " · " + data.path : ""} · ${lines.length} lines · ` + new Date().toLocaleTimeString();
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+async function clearPanelLog() {
+ const st = document.getElementById("systemLogStatus");
+ if (!confirm("Clean the panel log now? Logs are already auto-cleaned after 1 MiB.")) return;
+ st.textContent = "Cleaning panel log…";
+ try {
+ const res = await api("/api/system/logs/reset", { method:"POST" });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ st.textContent = `Panel log cleaned · ${data.path || "panel.log"} · max ${fmtBytes(data.max_bytes || 1048576)}`;
+ await loadSystemLogs();
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error cleaning panel log: " + e.message;
+ }
+}
+
+// ─── Server Config ────────────────────────────────────────────────────────────
+document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig);
+
+function toggleDnsttFields(on) {
+ const el = document.getElementById("dnsttFields");
+ el.style.opacity = on ? "1" : ".4";
+ el.style.pointerEvents = on ? "" : "none";
+}
+function toggleUdpgwFields(on) {
+ const el = document.getElementById("udpgwFields");
+ el.style.opacity = on ? "1" : ".4";
+ el.style.pointerEvents = on ? "" : "none";
+}
+
+async function loadServerConfig() {
+ const st = document.getElementById("srvCfgStatus");
+ st.textContent = "Loading…";
+ try {
+ const res = await api("/api/server/config");
+ if (!res.ok) throw new Error(await res.text());
+ const c = await res.json();
+
+ // Network
+ document.getElementById("cfgListen").value = c.listen || "";
+ document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n");
+
+ // SSH / general
+ document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
+ document.getElementById("cfgLimitDown").value = c.default_limit_mbps_down || 0;
+ document.getElementById("cfgQuiet").checked = !!c.quiet;
+ document.getElementById("cfgUserCount").checked = !!c.user_count;
+
+ // Banner
+ document.getElementById("cfgBanner").value = c.banner || "";
+
+ // DNSTT
+ const hasDnstt = !!c.dnstt;
+ document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
+ toggleDnsttFields(hasDnstt);
+ const d = c.dnstt || {};
+ document.getElementById("cfgDnsttDomain").value = d.domain || "";
+ document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
+ document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
+ document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
+ document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
+
+ // UDPGW
+ const hasUdpgw = !!c.udpgw;
+ document.getElementById("cfgUdpgwEnabled").checked = hasUdpgw;
+ toggleUdpgwFields(hasUdpgw);
+ const u = c.udpgw || {};
+ document.getElementById("cfgUdpgwListen").value = u.listen || "";
+ document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
+ document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
+ document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || "";
+ document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
+
+ // TLS forwarders
+ tlsForwardersState = c.tls_forwarders || [];
+ renderTLSForwarders();
+
+ // Xray
+ const x = c.xray || {};
+ document.getElementById("cfgXrayEnabled").checked = !!x.enabled;
+
+ st.textContent = "Config loaded.";
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+async function saveServerConfig() {
+ const st = document.getElementById("srvCfgStatus");
+ st.textContent = "Saving…";
+
+ const tlsArr = tlsForwardersState;
+
+ const extraLines = document.getElementById("cfgExtraListen").value
+ .split("\n").map(s => s.trim()).filter(Boolean);
+
+ const cfg = {
+ listen: document.getElementById("cfgListen").value.trim(),
+ extra_listen: extraLines,
+ host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
+ admin_dir: "/opt/sshpanel/admin",
+ default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
+ default_limit_mbps_down: parseInt(document.getElementById("cfgLimitDown").value || "0", 10),
+ quiet: document.getElementById("cfgQuiet").checked,
+ user_count: document.getElementById("cfgUserCount").checked,
+ banner: document.getElementById("cfgBanner").value,
+ banner_file: "/opt/sshpanel/banner.txt",
+ dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
+ domain: document.getElementById("cfgDnsttDomain").value.trim(),
+ udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
+ privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
+ disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
+ disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
+ } : null,
+ udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
+ listen: document.getElementById("cfgUdpgwListen").value.trim(),
+ max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
+ idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
+ map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(),
+ debug: document.getElementById("cfgUdpgwDebug").checked,
+ } : null,
+ tls_forwarders: tlsArr,
+ xray: {
+ enabled: document.getElementById("cfgXrayEnabled").checked,
+ bin_path: "/opt/sshpanel/xray",
+ config_file: "/opt/sshpanel/xray_config.json",
+ },
+ };
+
+ try {
+ const res = await api("/api/server/config", { method: "POST", body: JSON.stringify(cfg) });
+ if (!res.ok) throw new Error(await res.text());
+ const report = await res.json().catch(() => null);
+ const warnings = report?.warnings || [];
+ const bad = Object.entries(report?.services || {}).filter(([_, v]) => v?.enabled && !v?.running);
+ if (warnings.length || bad.length) {
+ const badText = bad.map(([name, v]) => `${name}: ${v.error || "not running"}`).join(" | ");
+ st.textContent = "Saved live with warnings: " + [...warnings, badText].filter(Boolean).join(" | ");
+ } else {
+ st.textContent = "Saved and applied live.";
+ }
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── TLS Forwarders ────────────────────────────────────────────────────────────
+function renderTLSForwarders() {
+ const list = document.getElementById("tlsForwardersList");
+ const chip = document.getElementById("tlsCountChip");
+ if (!list) return;
+ chip.textContent = tlsForwardersState.length;
+ if (!tlsForwardersState.length) {
+ list.innerHTML = 'No TLS forwarders configured.
';
+ return;
+ }
+ list.innerHTML = "";
+ tlsForwardersState.forEach((fw, i) => {
+ const row = document.createElement("div");
+ row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
+ row.innerHTML = `${fw.listen}
+ ${fw.cert_file ? fw.cert_file.split("/").pop() : "no cert"}`;
+ const delBtn = document.createElement("button");
+ delBtn.className = "btn btn-danger btn-sm";
+ delBtn.textContent = "Remove";
+ delBtn.onclick = () => { tlsForwardersState.splice(i,1); renderTLSForwarders(); };
+ row.appendChild(delBtn);
+ list.appendChild(row);
+ });
+}
+
+function toggleAddTLSForm() {
+ const panel = document.getElementById("addTLSPanel");
+ panel.classList.toggle("hidden");
+ if (!panel.classList.contains("hidden")) {
+ document.getElementById("tlsAddStatus").textContent = "";
+ document.getElementById("tlsListenAddr").value = "";
+ document.getElementById("tlsSSLDomain").value = "";
+ document.getElementById("tlsCertType").value = "selfsigned";
+ onTLSTypeChange("selfsigned");
+ }
+}
+
+function onTLSTypeChange(val) {
+ document.getElementById("tlsSSFields").style.display = val === "selfsigned" ? "" : "none";
+ document.getElementById("tlsLEFields").style.display = val === "letsencrypt" ? "grid" : "none";
+ document.getElementById("tlsPasteFields").style.display = val === "paste" ? "" : "none";
+ document.getElementById("tlsCustomFields").style.display = val === "custom" ? "grid" : "none";
+}
+
+async function addTLSForwarder() {
+ const st = document.getElementById("tlsAddStatus");
+ const listen = document.getElementById("tlsListenAddr").value.trim();
+ const certType = document.getElementById("tlsCertType").value;
+ if (!listen) { st.textContent = "Listen address required."; return; }
+ let certFile = "", keyFile = "";
+ st.textContent = "Processing…";
+ if (certType === "selfsigned") {
+ const domain = document.getElementById("tlsSSLDomain").value.trim();
+ if (!domain) { st.textContent = "Domain required."; return; }
+ try {
+ const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ certFile = data.cert_file; keyFile = data.key_file;
+ st.textContent = "Self-signed cert generated.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Cert error: " + e.message;
+ return;
+ }
+ } else if (certType === "letsencrypt") {
+ const domain = document.getElementById("tlsLEDomain").value.trim();
+ const email = document.getElementById("tlsLEEmail").value.trim();
+ if (!domain || !email) { st.textContent = "Domain and email required."; return; }
+ st.textContent = "Running certbot… (may take ~30s)";
+ try {
+ const res = await api("/api/tls/letsencrypt", { method:"POST", body: JSON.stringify({ domain, email }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ certFile = data.cert_file; keyFile = data.key_file;
+ st.textContent = "Let's Encrypt cert issued.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "certbot error: " + e.message;
+ return;
+ }
+ } else if (certType === "paste") {
+ const name = document.getElementById("tlsPasteName").value.trim();
+ const cert = document.getElementById("tlsPasteCert").value.trim();
+ const key = document.getElementById("tlsPasteKey").value.trim();
+ if (!name || !cert || !key) { st.textContent = "Name, cert PEM, and key PEM required."; return; }
+ try {
+ const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ certFile = data.cert_file; keyFile = data.key_file;
+ st.textContent = "PEM saved.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Upload error: " + e.message;
+ return;
+ }
+ } else {
+ certFile = document.getElementById("tlsCustomCert").value.trim();
+ keyFile = document.getElementById("tlsCustomKey").value.trim();
+ if (!certFile || !keyFile) { st.textContent = "Cert and key paths required."; return; }
+ }
+ tlsForwardersState.push({ listen, cert_file: certFile, key_file: keyFile });
+ renderTLSForwarders();
+ document.getElementById("addTLSPanel").classList.add("hidden");
+ st.textContent = "Added. Save config to apply.";
+}
+
+// ─── Xray wizard cert source picker ──────────────────────────────────────────
+function setWzCertSrc(mode) {
+ ["file","paste","gen"].forEach(m => {
+ const cap = m.charAt(0).toUpperCase() + m.slice(1);
+ document.getElementById("wzCertSrc"+cap).style.display = m === mode ? "" : "none";
+ const btn = document.getElementById("wzCertSrc"+cap+"Btn");
+ if (btn) btn.className = (m === mode ? "btn btn-sm" : "btn btn-ghost btn-sm");
+ });
+ document.getElementById("wzPasteCertStatus").textContent = "";
+ document.getElementById("wzGenCertStatus").textContent = "";
+}
+
+async function wzSavePastedCert() {
+ const st = document.getElementById("wzPasteCertStatus");
+ const name = document.getElementById("wzPastedName").value.trim();
+ const cert = document.getElementById("wzPastedCert").value.trim();
+ const key = document.getElementById("wzPastedKey").value.trim();
+ if (!name || !cert || !key) { st.textContent = "Name, cert, and key required."; return; }
+ st.textContent = "Saving…";
+ try {
+ const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ document.getElementById("wzTLSCert").value = data.cert_file;
+ document.getElementById("wzTLSKey").value = data.key_file;
+ st.textContent = "Saved ✓ paths set.";
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+async function wzGenerateCert() {
+ const st = document.getElementById("wzGenCertStatus");
+ const domain = document.getElementById("wzGenDomain").value.trim();
+ if (!domain) { st.textContent = "Domain required."; return; }
+ st.textContent = "Generating…";
+ try {
+ const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ document.getElementById("wzTLSCert").value = data.cert_file;
+ document.getElementById("wzTLSKey").value = data.key_file;
+ st.textContent = "Generated ✓ paths set.";
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── DNSTT Key Management ─────────────────────────────────────────────────────
+async function generateDnsttKey() {
+ const st = document.getElementById("dnsttKeyStatus");
+ st.textContent = "Generating key…";
+ try {
+ const res = await api("/api/dnstt/genkey", { method: "POST" });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ document.getElementById("cfgDnsttKey").value = data.privkey_file;
+ document.getElementById("dnsttPubkeyVal").value = data.pubkey;
+ document.getElementById("dnsttPubkeyWrap").classList.remove("hidden");
+ st.textContent = "Key generated. Save config to apply.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+async function loadDnsttPubkey() {
+ const st = document.getElementById("dnsttKeyStatus");
+ st.textContent = "Loading public key…";
+ try {
+ const res = await api("/api/dnstt/pubkey");
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ document.getElementById("dnsttPubkeyVal").value = data.pubkey;
+ document.getElementById("dnsttPubkeyWrap").classList.remove("hidden");
+ st.textContent = "";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── Xray Client Edit ─────────────────────────────────────────────────────────
+function openEditXrayClient(tag, client) {
+ editingXrayClientId = client.id;
+ document.getElementById("editClientUUID").textContent = client.id;
+ document.getElementById("editXrayName").value = client.name || "";
+ document.getElementById("editXrayEmail").value = client.email || "";
+ document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
+ document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
+ document.getElementById("editXrayClientStatus").textContent = "";
+ document.getElementById("editXrayClientPanel").classList.remove("hidden");
+ document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
+}
+
+function closeEditXrayClient() {
+ editingXrayClientId = null;
+ document.getElementById("editXrayClientPanel").classList.add("hidden");
+}
+
+async function saveEditXrayClient() {
+ if (!editingXrayClientId) return;
+ const st = document.getElementById("editXrayClientStatus");
+ st.textContent = "Saving…";
+ const payload = {
+ uuid: editingXrayClientId,
+ name: document.getElementById("editXrayName").value.trim(),
+ email: document.getElementById("editXrayEmail").value.trim(),
+ expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
+ max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
+ };
+ try {
+ const res = await api("/api/xray/clients/update", { method:"POST", body: JSON.stringify(payload) });
+ if (!res.ok) throw new Error(await res.text());
+ st.textContent = "Saved.";
+ setTimeout(() => { closeEditXrayClient(); loadInbounds(); }, 700);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── Xray Config Wizard ────────────────────────────────────────────────────────
+function setXrayCfgMode(mode) {
+ const wizPane = document.getElementById("xrayWizardPane");
+ const jsonPane = document.getElementById("xrayCfgPaneJson");
+ const wizBtn = document.getElementById("xrayWizardTabBtn");
+ const jsonBtn = document.getElementById("xrayJsonTabBtn");
+ if (mode === "wizard") {
+ wizPane.classList.remove("hidden");
+ jsonPane.classList.add("hidden");
+ wizBtn.classList.remove("btn-ghost");
+ jsonBtn.classList.add("btn-ghost");
+ loadWizardFromConfig();
+ } else {
+ wizPane.classList.add("hidden");
+ jsonPane.classList.remove("hidden");
+ jsonBtn.classList.remove("btn-ghost");
+ wizBtn.classList.add("btn-ghost");
+ loadXrayCfg();
+ }
+}
+
+function loadWizardFromConfig() {
+ api("/api/xray/config").then(async res => {
+ if (!res.ok) return;
+ try {
+ const cfg = JSON.parse(await res.text());
+ document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
+ wzInbounds = cfg.inbounds || [];
+ renderWzInbounds();
+ } catch {}
+ }).catch(() => {});
+}
+
+function renderWzInbounds() {
+ const list = document.getElementById("wzInboundsList");
+ if (!list) return;
+ if (!wzInbounds.length) {
+ list.innerHTML = 'No inbounds. Click + Add to create one.
';
+ return;
+ }
+ list.innerHTML = "";
+ wzInbounds.forEach((ib, i) => {
+ const row = document.createElement("div");
+ row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
+ const portStr = ib.port !== undefined ? `:${ib.port}` : "";
+ const ss = ib.streamSettings || {};
+ const net = ss.network || "";
+ const sec = ss.security || "";
+ const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : "";
+ const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : "";
+ row.innerHTML = `${ib.protocol}
+ ${ib.tag||"untagged"}${portStr}
+ ${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel}`;
+ const clients = ib.settings?.clients;
+ if (Array.isArray(clients) && clients.length) {
+ const badge = document.createElement("span");
+ badge.className = "chip green";
+ badge.textContent = clients.length + " client" + (clients.length!==1?"s":"");
+ row.appendChild(badge);
+ }
+ const delBtn = document.createElement("button");
+ delBtn.className = "btn btn-danger btn-sm";
+ delBtn.textContent = "Remove";
+ delBtn.onclick = () => { wzInbounds.splice(i,1); renderWzInbounds(); };
+ row.appendChild(delBtn);
+ list.appendChild(row);
+ });
+}
+
+function wzToggleAddInbound() {
+ const form = document.getElementById("wzAddInboundForm");
+ form.classList.toggle("hidden");
+ if (!form.classList.contains("hidden")) {
+ onWzProtoChange(document.getElementById("wzProtocol").value);
+ onWzNetworkChange(document.getElementById("wzNetwork").value);
+ onWzTLSChange(document.getElementById("wzTLS").value);
+ }
+}
+
+function onWzProtoChange(val) {
+ const usesClientTransport = val === "vless" || val === "vmess";
+ document.getElementById("wzVlessFields").style.display = usesClientTransport ? "grid" : "none";
+ document.getElementById("wzTrojanFields").style.display = val === "trojan" ? "" : "none";
+ document.getElementById("wzSSFields").style.display = val === "shadowsocks" ? "grid" : "none";
+
+ const tlsSel = document.getElementById("wzTLS");
+ const realityOpt = document.querySelector("#wzTLS option[value='reality']");
+ if (realityOpt) {
+ realityOpt.disabled = val === "vmess";
+ if (val === "vmess" && tlsSel.value === "reality") {
+ tlsSel.value = "none";
+ onWzTLSChange("none");
+ }
+ }
+
+ const portMap = { vless:10086, vmess:10087, trojan:8443, shadowsocks:8388, socks:10808 };
+ const tagMap = { vless:"vless-in", vmess:"vmess-in", trojan:"trojan-in", shadowsocks:"ss-in", socks:"socks-local" };
+ const portEl = document.getElementById("wzPort");
+ const tagEl = document.getElementById("wzTag");
+ const lisEl = document.getElementById("wzListenIP");
+ const knownPorts = Object.values(portMap).map(String);
+ const knownTags = Object.values(tagMap);
+ if (!portEl.value || knownPorts.includes(portEl.value)) portEl.value = portMap[val] || "";
+ if (!tagEl.value || knownTags.includes(tagEl.value)) tagEl.value = tagMap[val] || val+"-in";
+ if (!lisEl.value || lisEl.value === "0.0.0.0" || lisEl.value === "127.0.0.1") {
+ lisEl.value = val === "socks" ? "127.0.0.1" : "0.0.0.0";
+ }
+}
+
+function onWzNetworkChange(val) {
+ const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
+ // WebSocket
+ show("wzWSPathField", val === "ws");
+ // XHTTP
+ show("wzXHTTPPathField", val === "xhttp");
+ show("wzXHTTPHostField", val === "xhttp");
+ show("wzXHTTPModeField", val === "xhttp");
+ // HTTPUpgrade
+ show("wzHUPathField", val === "httpupgrade");
+ show("wzHUHostField", val === "httpupgrade");
+ // H2
+ show("wzH2PathField", val === "h2");
+ show("wzH2HostField", val === "h2");
+ // gRPC
+ show("wzGRPCServiceField", val === "grpc");
+ show("wzGRPCMultiField", val === "grpc");
+ // Auto-select TLS defaults
+ const tlsSel = document.getElementById("wzTLS");
+ if ((val === "h2" || val === "grpc") && tlsSel.value === "none") {
+ tlsSel.value = "tls"; onWzTLSChange("tls");
+ }
+}
+
+function onWzTLSChange(val) {
+ const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
+ show("wzTLSCertBlock", val === "tls");
+ show("wzRealityDestField", val === "reality");
+ show("wzRealitySNIField", val === "reality");
+ show("wzRealityPrivField", val === "reality");
+ show("wzRealityShortIDField",val === "reality");
+}
+
+function wzSaveInbound() {
+ const proto = document.getElementById("wzProtocol").value;
+ const port = parseInt(document.getElementById("wzPort").value || "0", 10);
+ const listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0";
+ const tag = document.getElementById("wzTag").value.trim() || proto+"-in";
+ if (!port) { alert("Port required."); return; }
+ const ib = { tag, port, listen, protocol: proto, settings: {} };
+ if (proto === "vless" || proto === "vmess") {
+ ib.settings = proto === "vless" ? { clients: [], decryption: "none" } : { clients: [] };
+ const net = document.getElementById("wzNetwork").value;
+ const tlsVal = document.getElementById("wzTLS").value;
+ ib.streamSettings = { network: net };
+ // Transport-specific settings
+ switch (net) {
+ case "ws":
+ ib.streamSettings.wsSettings = { path: document.getElementById("wzWSPath").value.trim() || "/" };
+ break;
+ case "xhttp":
+ ib.streamSettings.xhttpSettings = {
+ path: document.getElementById("wzXHTTPPath").value.trim() || "/",
+ host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
+ mode: document.getElementById("wzXHTTPMode").value,
+ };
+ if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
+ break;
+ case "httpupgrade":
+ ib.streamSettings.httpupgradeSettings = {
+ path: document.getElementById("wzHUPath").value.trim() || "/",
+ host: document.getElementById("wzHUHost").value.trim() || undefined,
+ };
+ if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host;
+ break;
+ case "h2":
+ ib.streamSettings.httpSettings = {
+ path: document.getElementById("wzH2Path").value.trim() || "/",
+ host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean),
+ };
+ break;
+ case "grpc":
+ ib.streamSettings.grpcSettings = {
+ serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc",
+ multiMode: document.getElementById("wzGRPCMulti").checked,
+ };
+ break;
+ }
+ // TLS / Reality
+ if (tlsVal === "tls") {
+ ib.streamSettings.security = "tls";
+ ib.streamSettings.tlsSettings = {
+ certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }],
+ };
+ } else if (tlsVal === "reality" && proto === "vless") {
+ ib.streamSettings.security = "reality";
+ ib.streamSettings.realitySettings = {
+ dest: document.getElementById("wzRealityDest").value.trim(),
+ serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean),
+ privateKey: document.getElementById("wzRealityPriv").value.trim(),
+ shortIds: [document.getElementById("wzRealityShortID").value.trim()].filter(Boolean),
+ };
+ }
+ } else if (proto === "trojan") {
+ ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] };
+ ib.streamSettings = { network: "tcp", security: "tls", tlsSettings: {} };
+ } else if (proto === "shadowsocks") {
+ ib.settings = { method: document.getElementById("wzSSMethod").value, password: document.getElementById("wzSSPass").value.trim() || "change-me", network: "tcp,udp" };
+ } else if (proto === "socks") {
+ ib.settings = { auth: "noauth", udp: true };
+ ib.streamSettings = { network: "tcp" };
+ }
+ wzInbounds.push(ib);
+ renderWzInbounds();
+ document.getElementById("wzAddInboundForm").classList.add("hidden");
+ document.getElementById("wzPort").value = "";
+ document.getElementById("wzTag").value = "";
+ document.getElementById("wzListenIP").value = "";
+}
+
+async function applyWizardConfig() {
+ const st = document.getElementById("wzStatus");
+ st.textContent = "Saving…";
+ const cfg = {
+ log: { loglevel: document.getElementById("wzLogLevel").value },
+ inbounds: wzInbounds,
+ outbounds: [
+ { tag:"direct", protocol:"freedom", settings:{} },
+ { tag:"blocked", protocol:"blackhole", settings:{} }
+ ]
+ };
+ try {
+ const res = await api("/api/xray/config", { method:"POST", body: JSON.stringify(cfg, null, 2) });
+ if (!res.ok) throw new Error(await res.text());
+ st.textContent = "Saved. Restarting Xray…";
+ await xrayCtrl("restart");
+ st.textContent = "Config saved and Xray restarted.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── Auth error ───────────────────────────────────────────────────────────────
+function doAuthError() {
+ sessionToken = "";
+ localStorage.removeItem("SESSION_TOKEN");
+ clearTimers();
+ mainApp.classList.add("hidden");
+ loginOverlay.classList.remove("hidden");
+ loginErr.textContent = "Session expired — please sign in again.";
+}
+
+// ─── Boot ─────────────────────────────────────────────────────────────────────
+window.addEventListener("load", () => {
+ if (sessionToken) {
+ // Try to validate the stored token
+ api("/api/auth/me").then(async res => {
+ if (!res.ok) { doAuthError(); return; }
+ const d = await res.json();
+ currentRole = d.role;
+ currentUser = d.username;
+ loginOverlay.classList.add("hidden");
+ mainApp.classList.remove("hidden");
+ initAfterLogin();
+ }).catch(() => doAuthError());
+ } else {
+ loginOverlay.classList.remove("hidden");
+ }
+});
diff --git a/vnstat_api.go b/vnstat_api.go
index 10b0a66..cf85a9d 100644
--- a/vnstat_api.go
+++ b/vnstat_api.go
@@ -80,9 +80,14 @@ type VnstatUsageRow struct {
}
type VnstatDTO struct {
- Daily []VnstatUsageRow `json:"daily"`
- Monthly []VnstatUsageRow `json:"monthly"`
- UpdatedAt time.Time `json:"updated_at"`
+ Daily []VnstatUsageRow `json:"daily"`
+ Monthly []VnstatUsageRow `json:"monthly"`
+ UpdatedAt time.Time `json:"updated_at"`
+ TodayPeriod string `json:"today_period"`
+ MonthPeriod string `json:"month_period"`
+ TodayTotalBytes uint64 `json:"today_total_bytes"`
+ MonthTotalBytes uint64 `json:"month_total_bytes"`
+ InterfaceCount int `json:"interface_count"`
}
func (s *Store) EnsureIfaceUsageTables(ctx context.Context) error {
@@ -167,7 +172,11 @@ func (s *Store) LoadIfaceUsage(ctx context.Context, days, months int) (VnstatDTO
months = 12
}
- out := VnstatDTO{UpdatedAt: time.Now()}
+ now := time.Now()
+ todayPeriod := now.Format("2006-01-02")
+ monthPeriod := now.Format("2006-01")
+ out := VnstatDTO{UpdatedAt: now, TodayPeriod: todayPeriod, MonthPeriod: monthPeriod}
+ ifaceSet := make(map[string]struct{})
dailyRows, err := s.db.QueryContext(ctx, `
SELECT iface, usage_date::text, rx_bytes, tx_bytes
@@ -186,6 +195,10 @@ func (s *Store) LoadIfaceUsage(ctx context.Context, days, months int) (VnstatDTO
}
r.TotalBytes = r.RxBytes + r.TxBytes
out.Daily = append(out.Daily, r)
+ ifaceSet[r.Iface] = struct{}{}
+ if r.Period == todayPeriod {
+ out.TodayTotalBytes += r.TotalBytes
+ }
}
if err := dailyRows.Err(); err != nil {
return out, err
@@ -208,11 +221,17 @@ func (s *Store) LoadIfaceUsage(ctx context.Context, days, months int) (VnstatDTO
}
r.TotalBytes = r.RxBytes + r.TxBytes
out.Monthly = append(out.Monthly, r)
+ ifaceSet[r.Iface] = struct{}{}
+ if r.Period == monthPeriod {
+ out.MonthTotalBytes += r.TotalBytes
+ }
}
if err := monthlyRows.Err(); err != nil {
return out, err
}
+ out.InterfaceCount = len(ifaceSet)
+
return out, nil
}