-
-
-
- ●
-
-
- INTERNET STATUS
- CHECKING...
-
-
- LAN
- ---.---.---.---
+
+
+
+
-
+
- ●
+
+
INTERNET STATUS
+ CHECKING...
+
- WAN
- ---.---.---.---
+
-
+
+ LAN
+ ---.---.---.---
+
+
+ WAN
+ ---.---.---.---
+
+
+ DOWNLOAD
+ 0 Mbps
+
+
+ UPLOAD
+ 0 Mbps
+
-
-
↓
-
-
0 Mbps
- DOWNLOAD
+
+
-
+
- CPU USAGE
+ 0%
+
+
+
-
↑
-
-
- 0 Mbps
- UPLOAD
+
+
- MEMORY
+ Loading...
+
+
+
-
- ◆
-
-
- 0
- DEVICES
+
+
+ UPTIME
+ Loading...
+
+
DEVICES
+ 0
-
NETWORK ACTIVITY (60s)
-
-
+
+
- NETWORK ACTIVITY (60s)
+
+
+
+
+
+
+ Download
+
+
+
+ Upload
+
+
-
-
- Download
+
+
+
-
+
+
+ TRAFFIC HISTORY (VNSTAT)
+
+
+
+
+
+
+
+ Loading monthly data...
+
+
-
-
- Upload
+
-
+
+
+ Download
+
+
+
+ Upload
+
-
-
CPU USAGE
- 0%
-
-
+
+
+
+ ACTIVE CONNECTIONS
+ Loading...
+ Polling conntrack...
+
+
SYSTEM LOG
-
MEMORY
- Loading...
-
-
+
-
+
+
+
+
Loading system logs...
-
- UPTIME
- Loading...
-
-
- HOSTNAME
- Loading...
+
+ QUICK ACTIONS
+
+
+
+
QUICK ACTIONS
-
-
-
-
-
+
+
@@ -431,6 +547,7 @@
NAME
SOURCE
+ SOURCE IP
DESTINATION
PROTOCOL
PORT
@@ -440,7 +557,7 @@
-
+
Loading...
@@ -494,8 +611,10 @@
DEVICES
+
+
+
+
+
-
+
+
+
+ DEVICES
+
+
+ | HOSTNAME | +IP ADDRESS | +MAC ADDRESS | +UPLOAD | +DOWNLOAD | +ONLINE | +ACTIONS | +
|---|---|---|---|---|---|---|
| + Loading... + | +||||||
SYSTEM LOG
-
-
-
- Loading system logs...
+
+
+
+
+
+ DEVICE SETTINGS
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NETWORK
@@ -187,9 +301,11 @@NETWORK
+ +CONFIGURE WIRELESS
CONFIGURE WIRELESS
PORT FORWARDING RULE
type="text" id="edit-forward-dest-ip" class="form-input" + list="forward-device-list" placeholder="192.168.1.100" /> +
@@ -599,6 +718,16 @@
+
+ FIREWALL RULE
placeholder="0.0.0.0/0 for any" />
+
+
+
@@ -1136,30 +1265,43 @@
SCHEDULED TASK
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -1260,15 +1402,15 @@
SCHEDULED TASK
+
-
+
+
+
+
@@ -1531,7 +2171,7 @@
ACTIVE CONNECTIONS
- | IP ADDRESS | -MAC ADDRESS | -HOSTNAME | +SOURCE | +DESTINATION | +PROTOCOL | STATUS |
|---|
+
+
+
+
ADBLOCK-FAST CONFIGURATION
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TARGET LISTS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | NAME | +URL | +STATUS | +ACTIONS | +
|---|---|---|---|
| + Loading... + | +|||
+
+
+
-
- PING
SCHEDULED TASK
+
+
NSLOOKUP
+
+
+
+
+
+
+
+ Enter a hostname/domain and click Run Nslookup
+
+ WAKE-ON-LAN
@@ -1326,20 +1556,391 @@
+ SCHEDULED TASK
id="wol-mac" placeholder="Enter MAC address (aa:bb:cc:dd:ee:ff)" class="diag-input" + list="wol-device-list" /> +
+ Start typing to pick a DHCP lease, or enter a MAC manually.
+
Enter a MAC address to wake a device on the network
-
@@ -1422,7 +2034,28 @@ SYSTEM
+
+
+
+ MONITORING
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CURRENT LATENCY
+ N/A
+
+
+ STATUS
+
+
+
+ AVERAGE (LAST 12)
+ 0.0 ms
+
+
+ PACKET LOSS (LAST 12)
+ 0%
+
+
+
+ NETWORK PERFORMANCE TIMELINE
+
+
+
+
+
+
+
+
+
+
+ RECENT PING SAMPLES
+ | TIME | +TARGET | +LATENCY | +STATUS | +
|---|---|---|---|
| No ping samples yet | +|||
+
+
+
+
+ PING SETTINGS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DAILY SPEEDTEST (SPEEDTESTCPP)
+
+
+
+
+ LAST DOWNLOAD
+ N/A
+
+
+ LAST UPLOAD
+ N/A
+
+
+ LAST RUN
+ Never
+
+
+
+
+ | DATE | +DOWNLOAD | +UPLOAD | +STATUS | +
|---|---|---|---|
| No speedtest samples yet | +|||
+
+
+
+
+ DAILY SPEEDTEST SETTINGS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NETIFY
+
+
+
+
+
+ FLOWS
+ 0
+
+
+ DEVICES
+ 0
+
+
+ APPLICATIONS
+ 0
+
+
+ TOTAL BYTES
+ 0 B
+
+
+
+
+
+
+ TOP APPLICATIONS
+ | APPLICATION | +FLOWS | +LAST SEEN | +
|---|---|---|
| Loading... | +||
+
+ 0-0 of 0
+
+
+
+
+
+
+
+
+
+
+
+
+ RECENT FLOWS
+
+
+
+
+
+
+
+ | TIME | +DEVICE | +LOCAL IP | +FQDN | +APPLICATION | +PROTOCOL | +DESTINATION IP | +PORT | +
|---|---|---|---|---|---|---|---|
| Loading... | +|||||||
+
+ 0-0 of 0
+
+
+
+
+
+
+
+
+
+
+
+
+ FLOW ACTION
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ COLLECTOR STATUS
+
+
+
+
+
+
+
+ SERVICE
+ Loading...
+
+
+ SQLITE DB
+ Loading...
+
+
+ DB PATH
+
+ /tmp/moci-netify.sqlite
+
+
+
+
+
+
+
+
+
+
+
+ WEBUI DEBUG LOG
+
+
+
+
+
+
+
+ SYSTEM
@@ -1366,6 +1967,17 @@
SYSTEM
+
+
+
+
MOCI CONFIG (check/uncheck to hide/disable features)
+
+
+ Loading...
+
+
SYSTEM
+
+
ROOT STORAGE (/)
+
+
+
+ Used: N/A
+
+
+
+
+ INSTALLED PACKAGES
+
+
+
+
+ 0-0 of 0
+
+
+
+
SYSTEM
> -
+
diff --git a/moci/js/core.js b/moci/js/core.js
index b69dd34..eb15752 100644
--- a/moci/js/core.js
+++ b/moci/js/core.js
@@ -18,8 +18,11 @@ export class OpenWrtCore {
getModuleForRoute(basePath) {
const routeModuleMap = {
dashboard: 'dashboard',
+ devices: 'devices',
network: 'network',
- system: 'system'
+ monitoring: 'monitoring',
+ system: 'system',
+ netify: 'netify'
};
return routeModuleMap[basePath];
}
@@ -99,28 +102,37 @@ export class OpenWrtCore {
}
async loadFeatures() {
+ const defaults = this.getDefaultFeatures();
try {
const [status, result] = await this.uciGet('moci', 'features');
if (status === 0 && result && result.values) {
- this.features = result.values;
+ // Merge router config over defaults so newly added features remain visible.
+ this.features = { ...defaults, ...result.values };
} else {
- this.features = this.getDefaultFeatures();
+ this.features = defaults;
}
} catch (err) {
console.error('Feature config not found, using defaults:', err);
- this.features = this.getDefaultFeatures();
+ this.features = defaults;
}
}
getDefaultFeatures() {
return {
dashboard: '1',
+ devices: '1',
network: '1',
+ traffic_history: '1',
+ monitoring: '1',
+ netify: '1',
+ show_lan_ip: '0',
+ colorful_graphs: '0',
wireless: '1',
firewall: '1',
dhcp: '1',
dns: '1',
+ adblock: '1',
wireguard: '1',
qos: '1',
ddns: '1',
@@ -143,8 +155,13 @@ export class OpenWrtCore {
getModuleMap() {
return {
dashboard: './modules/dashboard.js',
+ devices: './modules/devices.js',
network: './modules/network.js',
- system: './modules/system.js'
+ monitoring: './modules/monitoring.js',
+ system: './modules/system.js',
+ netify: './modules/netify.js',
+ vpn: './modules/vpn.js',
+ services: './modules/services.js'
};
}
@@ -176,8 +193,13 @@ export class OpenWrtCore {
shouldLoadModule(moduleName) {
const moduleFeatures = {
dashboard: ['dashboard'],
- network: ['network', 'wireless', 'firewall', 'dhcp', 'dns', 'diagnostics', 'wireguard', 'qos', 'ddns'],
- system: ['system', 'backup', 'packages', 'services', 'ssh_keys', 'storage', 'leds', 'firmware']
+ devices: ['devices'],
+ network: ['network', 'wireless', 'firewall', 'dhcp', 'dns', 'adblock', 'diagnostics'],
+ monitoring: ['monitoring'],
+ system: ['system', 'backup', 'packages', 'services', 'ssh_keys', 'storage', 'leds', 'firmware'],
+ netify: ['netify'],
+ vpn: ['wireguard'],
+ services: ['qos', 'ddns']
};
const features = moduleFeatures[moduleName] || [];
@@ -202,21 +224,6 @@ export class OpenWrtCore {
attachEventListeners() {
document.getElementById('logout-btn')?.addEventListener('click', () => this.logout());
-
- const menuToggle = document.querySelector('.menu-toggle');
- const nav = document.querySelector('.nav');
- if (menuToggle && nav) {
- menuToggle.addEventListener('click', () => {
- nav.classList.toggle('open');
- menuToggle.setAttribute('aria-expanded', nav.classList.contains('open'));
- });
- nav.querySelectorAll('a').forEach(link => {
- link.addEventListener('click', () => nav.classList.remove('open'));
- });
- window.addEventListener('resize', () => {
- if (window.innerWidth > 768) nav.classList.remove('open');
- });
- }
}
startPolling() {
@@ -472,7 +479,7 @@ export class OpenWrtCore {
renderActionButtons(id) {
const eid = this.escapeHtml(id);
- return ``;
+ return ``;
}
showToast(message, type = 'info') {
@@ -608,135 +615,6 @@ export class OpenWrtCore {
return () => container.removeEventListener('click', handler);
}
- renderTable(tableSelector, items, colspan, emptyMsg, rowFn) {
- const tbody = document.querySelector(`${tableSelector} tbody`);
- if (!tbody) return;
- if (items.length === 0) {
- this.renderEmptyTable(tbody, colspan, emptyMsg);
- return;
- }
- tbody.innerHTML = items.map(rowFn).join('');
- }
-
- filterUciSections(config, type) {
- return Object.entries(config)
- .filter(([, v]) => v['.type'] === type)
- .map(([k, v]) => ({ section: k, ...v }));
- }
-
- getFormValues(fieldMap) {
- const values = {};
- for (const [elementId, uciKey] of Object.entries(fieldMap)) {
- const el = document.getElementById(elementId);
- if (!el) continue;
- const formVal = el.type === 'checkbox' ? el.checked : el.value;
- if (Array.isArray(uciKey)) {
- for (const key of uciKey) values[key] = formVal;
- } else {
- values[uciKey] = formVal;
- }
- }
- return values;
- }
-
- setFormValues(fieldMap, data) {
- for (const [elementId, uciKey] of Object.entries(fieldMap)) {
- const el = document.getElementById(elementId);
- if (!el) continue;
- let val;
- if (Array.isArray(uciKey)) {
- for (const key of uciKey) {
- if (data[key] !== undefined && data[key] !== '') {
- val = data[key];
- break;
- }
- }
- } else {
- val = data[uciKey];
- }
- if (el.type === 'checkbox') {
- el.checked = !!val;
- } else {
- el.value = Array.isArray(val) ? val.join(', ') : val || '';
- }
- }
- }
-
- async uciEdit(config, id, fieldMap, modalId, sectionIdField) {
- try {
- const [status, result] = await this.uciGet(config, id);
- if (status !== 0 || !result?.values) throw new Error('Not found');
- if (sectionIdField) document.getElementById(sectionIdField).value = id;
- this.setFormValues(fieldMap, result.values);
- this.openModal(modalId);
- } catch {
- this.showToast('Failed to load config', 'error');
- }
- }
-
- async uciSave({
- config,
- uciType,
- modalId,
- sectionIdField,
- fieldMap,
- defaults,
- reloadFn,
- successMsg,
- sectionNameField
- }) {
- const section = sectionIdField ? document.getElementById(sectionIdField)?.value : '';
- const values = { ...this.getFormValues(fieldMap), ...defaults };
- try {
- if (section) {
- await this.uciSet(config, section, values);
- } else {
- const name = sectionNameField ? document.getElementById(sectionNameField)?.value || null : null;
- const [, res] = await this.uciAdd(config, uciType, name);
- if (!res?.section) throw new Error('Failed to create section');
- await this.uciSet(config, res.section, values);
- }
- await this.uciCommit(config);
- this.closeModal(modalId);
- this.showToast(successMsg || 'Saved', 'success');
- if (reloadFn) await reloadFn();
- } catch {
- this.showToast('Failed to save', 'error');
- }
- }
-
- async uciDeleteEntry(config, id, confirmMsg, reloadFn) {
- if (!confirm(confirmMsg)) return;
- try {
- await this.uciDelete(config, id);
- await this.uciCommit(config);
- this.showToast('Deleted', 'success');
- if (reloadFn) await reloadFn();
- } catch {
- this.showToast('Failed to delete', 'error');
- }
- }
-
- spliceFileLines(raw, dataFilter, index, newLine) {
- const lines = raw.split('\n');
- const dataIndices = lines.map((l, i) => (dataFilter(l) ? i : -1)).filter(i => i >= 0);
- if (index !== '' && index !== undefined) {
- const origIdx = dataIndices[parseInt(index)];
- if (origIdx !== undefined) {
- if (newLine === null) {
- lines.splice(origIdx, 1);
- } else {
- lines[origIdx] = newLine;
- }
- }
- } else if (newLine !== null) {
- if (lines.length && lines[lines.length - 1] === '') lines.pop();
- lines.push(newLine);
- }
- const result = lines.join('\n');
- return result.endsWith('\n') ? result : result + '\n';
- }
-
resetModal(modalId) {
const modal = document.getElementById(modalId);
if (!modal) return;
diff --git a/moci/js/modules/dashboard.js b/moci/js/modules/dashboard.js
index eb68e04..a1688cc 100644
--- a/moci/js/modules/dashboard.js
+++ b/moci/js/modules/dashboard.js
@@ -7,22 +7,100 @@ export default class DashboardModule {
this.lastCpuStats = null;
this.bandwidthCanvas = null;
this.bandwidthCtx = null;
+ this.bandwidthHoverIndex = -1;
+ this.bandwidthHoverBound = false;
+ this.bandwidthTooltip = null;
+ this.monthlyCanvas = null;
+ this.monthlyCtx = null;
+ this.monthlyPoints = [];
+ this.monthlyHitboxes = [];
+ this.monthlyHoverIndex = -1;
+ this.monthlyHoverBound = false;
+ this.monthlyTooltip = null;
+ this.lastMonthlyRefresh = 0;
+ this.trafficPeriod = 'hourly';
+ this.trafficControlsBound = false;
+ this.systemLogLines = [];
+ this.systemLogQuery = '';
+ this.systemLogSearchBound = false;
this.core.registerRoute('/dashboard', () => this.load());
}
async fetchSystemInfo() {
const [status, result] = await this.core.ubusCall('system', 'info', {});
- if (status !== 0 || !result) throw new Error('Failed to fetch system info');
+ if (status !== 0 || !result) {
+ throw new Error('Failed to fetch system info');
+ }
return result;
}
async fetchBoardInfo() {
const [status, result] = await this.core.ubusCall('system', 'board', {});
- if (status !== 0 || !result) throw new Error('Failed to fetch board info');
+ if (status !== 0 || !result) {
+ throw new Error('Failed to fetch board info');
+ }
return result;
}
+ parseMemoryPercent(memory) {
+ return (((memory.total - memory.free) / memory.total) * 100).toFixed(0);
+ }
+
+ isColorfulGraphsEnabled() {
+ return this.core.isFeatureEnabled('colorful_graphs');
+ }
+
+ getGraphPalette() {
+ if (this.isColorfulGraphsEnabled()) {
+ return {
+ downloadStroke: 'rgba(132, 210, 255, 0.95)',
+ downloadFill: 'rgba(132, 210, 255, 0.18)',
+ uploadStroke: 'rgba(255, 193, 122, 0.92)',
+ uploadFill: 'rgba(255, 193, 122, 0.14)'
+ };
+ }
+
+ return {
+ downloadStroke: 'rgba(226, 226, 229, 0.9)',
+ downloadFill: 'rgba(226, 226, 229, 0.15)',
+ uploadStroke: 'rgba(226, 226, 229, 0.5)',
+ uploadFill: 'rgba(226, 226, 229, 0.08)'
+ };
+ }
+
+ applyDashboardColorTheme() {
+ const colorful = this.isColorfulGraphsEnabled();
+ const downEl = document.getElementById('bandwidth-down');
+ const upEl = document.getElementById('bandwidth-up');
+ if (downEl) downEl.style.color = colorful ? 'rgba(132, 210, 255, 0.98)' : '';
+ if (upEl) upEl.style.color = colorful ? 'rgba(255, 193, 122, 0.98)' : '';
+
+ const downloadLegend = colorful ? 'rgba(132, 210, 255, 0.95)' : 'rgba(226, 226, 229, 0.9)';
+ const uploadLegend = colorful ? 'rgba(255, 193, 122, 0.92)' : 'rgba(226, 226, 229, 0.5)';
+ document.querySelectorAll('.legend-color.legend-download').forEach(el => {
+ el.style.background = downloadLegend;
+ });
+ document.querySelectorAll('.legend-color.legend-upload').forEach(el => {
+ el.style.background = uploadLegend;
+ });
+ }
+
+ getUsageColor(percent) {
+ if (!this.isColorfulGraphsEnabled()) return '';
+ const value = Number(percent);
+ if (!Number.isFinite(value)) return '';
+ if (value > 92) return 'rgba(255, 170, 170, 0.98)';
+ if (value > 80) return 'rgba(255, 193, 122, 0.98)';
+ return '';
+ }
+
+ applyUsageStyling(valueEl, barEl, percent) {
+ const color = this.getUsageColor(percent);
+ if (valueEl) valueEl.style.color = color || '';
+ if (barEl) barEl.style.background = color || '';
+ }
+
renderSystemInfo(boardInfo, systemInfo) {
const hostnameEl = document.getElementById('hostname');
const uptimeEl = document.getElementById('uptime');
@@ -32,15 +110,19 @@ export default class DashboardModule {
if (hostnameEl) hostnameEl.textContent = boardInfo.hostname || 'OpenWrt';
if (uptimeEl) uptimeEl.textContent = this.core.formatUptime(systemInfo.uptime);
- const memTotal = systemInfo.memory.total || 1;
- const memPercent = (((memTotal - systemInfo.memory.free) / memTotal) * 100).toFixed(0);
+ const memPercent = this.parseMemoryPercent(systemInfo.memory);
if (memoryEl) memoryEl.textContent = this.core.formatMemory(systemInfo.memory);
if (memoryBarEl) memoryBarEl.style.width = memPercent + '%';
+ if (memoryEl) memoryEl.style.color = '';
+ this.applyUsageStyling(null, memoryBarEl, memPercent);
}
async load() {
const pageElement = document.getElementById('dashboard-page');
if (pageElement) pageElement.classList.remove('hidden');
+ this.applyLanVisibility();
+ this.applyDashboardColorTheme();
+ this.initSystemLogSearch();
try {
const systemInfo = await this.fetchSystemInfo();
const boardInfo = await this.fetchBoardInfo();
@@ -51,115 +133,240 @@ export default class DashboardModule {
await this.updateWANStatus();
await this.updateSystemLog();
await this.updateConnections();
+ await this.updateConntrackUsage();
this.initBandwidthGraph();
+ this.initTrafficControls();
+ this.initMonthlyGraph();
+ await this.updateTrafficChart(true);
} catch (err) {
console.error('Failed to load dashboard:', err);
this.core.showToast('Failed to load system information', 'error');
}
}
+ applyLanVisibility() {
+ const lanDetailEl = document.getElementById('lan-detail');
+ if (!lanDetailEl) return;
+
+ if (this.core.isFeatureEnabled('show_lan_ip')) {
+ lanDetailEl.classList.remove('hidden');
+ } else {
+ lanDetailEl.classList.add('hidden');
+ }
+ }
+
async update() {
await this.updateCpuUsage();
await this.updateNetworkStats();
await this.updateWANStatus();
+ await this.updateConntrackUsage();
+ await this.updateTrafficChart(false);
+ }
+
+ async fetchCpuStats() {
+ const [status, result] = await this.core.ubusCall('file', 'read', {
+ path: '/proc/stat'
+ });
+ if (status !== 0 || !result?.data) {
+ throw new Error('Failed to fetch CPU stats');
+ }
+ return result.data;
+ }
+
+ parseCpuStats(content) {
+ const cpuLine = content.split('\n')[0];
+ const values = cpuLine.split(/\s+/).slice(1).map(Number);
+ const idle = values[3];
+ const total = values.reduce((a, b) => a + b, 0);
+ return { idle, total };
+ }
+
+ calculateCpuUsage(current, previous) {
+ if (!previous) return null;
+ const idleDelta = current.idle - previous.idle;
+ const totalDelta = current.total - previous.total;
+ return ((1 - idleDelta / totalDelta) * 100).toFixed(1);
+ }
+
+ renderCpuUsage(usage) {
+ const cpuEl = document.getElementById('cpu');
+ const cpuBarEl = document.getElementById('cpu-bar');
+
+ if (usage !== null) {
+ if (cpuEl) cpuEl.textContent = usage + '%';
+ if (cpuBarEl) cpuBarEl.style.width = usage + '%';
+ this.applyUsageStyling(cpuEl, cpuBarEl, usage);
+ } else {
+ if (cpuEl) cpuEl.textContent = 'N/A';
+ this.applyUsageStyling(cpuEl, cpuBarEl, null);
+ }
}
async updateCpuUsage() {
try {
- const [status, result] = await this.core.ubusCall('file', 'read', { path: '/proc/stat' });
- if (status !== 0 || !result?.data) throw new Error('Failed');
- const cpuLine = result.data.split('\n')[0];
- const values = cpuLine.split(/\s+/).slice(1).map(Number);
- const current = { idle: values[3], total: values.reduce((a, b) => a + b, 0) };
-
- if (this.lastCpuStats) {
- const idleDelta = current.idle - this.lastCpuStats.idle;
- const totalDelta = current.total - this.lastCpuStats.total;
- const usage = totalDelta > 0 ? ((1 - idleDelta / totalDelta) * 100).toFixed(1) : '0.0';
- const cpuEl = document.getElementById('cpu');
- const cpuBarEl = document.getElementById('cpu-bar');
- if (cpuEl) cpuEl.textContent = usage + '%';
- if (cpuBarEl) cpuBarEl.style.width = usage + '%';
- }
- this.lastCpuStats = current;
- } catch {
- const cpuEl = document.getElementById('cpu');
- if (cpuEl) cpuEl.textContent = 'N/A';
- const cpuBarEl = document.getElementById('cpu-bar');
- if (cpuBarEl) cpuBarEl.style.width = '0%';
+ const content = await this.fetchCpuStats();
+ const currentStats = this.parseCpuStats(content);
+ const usage = this.calculateCpuUsage(currentStats, this.lastCpuStats);
+ this.renderCpuUsage(usage);
+ this.lastCpuStats = currentStats;
+ } catch (err) {
+ this.renderCpuUsage(null);
+ }
+ }
+
+ async fetchNetworkStats() {
+ const [status, result] = await this.core.ubusCall('file', 'read', {
+ path: '/proc/net/dev'
+ });
+ if (status !== 0 || !result?.data) {
+ throw new Error('Failed to fetch network stats');
+ }
+ return result.data;
+ }
+
+ parseNetworkStats(content) {
+ const lines = content.split('\n').slice(2);
+ let totalRx = 0,
+ totalTx = 0;
+
+ lines.forEach(line => {
+ if (!line.trim()) return;
+ const parts = line.trim().split(/\s+/);
+ if (parts[0].startsWith('lo:')) return;
+ totalRx += parseInt(parts[1]) || 0;
+ totalTx += parseInt(parts[9]) || 0;
+ });
+
+ return { rx: totalRx, tx: totalTx };
+ }
+
+ calculateBandwidthRates(current, previous) {
+ if (!previous) return null;
+ const rxRate = (current.rx - previous.rx) / 1024 / 3;
+ const txRate = (current.tx - previous.tx) / 1024 / 3;
+ return { rxRate, txRate };
+ }
+
+ renderBandwidthRates(rates) {
+ if (!rates) return;
+
+ const downEl = document.getElementById('bandwidth-down');
+ const upEl = document.getElementById('bandwidth-up');
+
+ if (downEl) downEl.textContent = this.core.formatRate(rates.rxRate);
+ if (upEl) upEl.textContent = this.core.formatRate(rates.txRate);
+ }
+
+ updateBandwidthHistory(rxRate, txRate) {
+ this.bandwidthHistory.down.push(rxRate);
+ this.bandwidthHistory.up.push(txRate);
+
+ if (this.bandwidthHistory.down.length > 60) {
+ this.bandwidthHistory.down.shift();
+ this.bandwidthHistory.up.shift();
}
}
async updateNetworkStats() {
try {
- const [status, result] = await this.core.ubusCall('file', 'read', { path: '/proc/net/dev' });
- if (status !== 0 || !result?.data) throw new Error('Failed');
-
- let totalRx = 0,
- totalTx = 0;
- result.data
- .split('\n')
- .slice(2)
- .forEach(line => {
- if (!line.trim()) return;
- const parts = line.trim().split(/\s+/);
- if (parts[0].startsWith('lo:')) return;
- totalRx += parseInt(parts[1]) || 0;
- totalTx += parseInt(parts[9]) || 0;
- });
-
- const current = { rx: totalRx, tx: totalTx };
- if (this.lastNetStats) {
- const rxRate = Math.max(0, current.rx - this.lastNetStats.rx) / 1024 / 3;
- const txRate = Math.max(0, current.tx - this.lastNetStats.tx) / 1024 / 3;
-
- const downEl = document.getElementById('bandwidth-down');
- const upEl = document.getElementById('bandwidth-up');
- if (downEl) downEl.textContent = this.core.formatRate(rxRate);
- if (upEl) upEl.textContent = this.core.formatRate(txRate);
-
- this.bandwidthHistory.down.push(rxRate);
- this.bandwidthHistory.up.push(txRate);
- if (this.bandwidthHistory.down.length > 60) {
- this.bandwidthHistory.down.shift();
- this.bandwidthHistory.up.shift();
- }
+ const content = await this.fetchNetworkStats();
+ const currentStats = this.parseNetworkStats(content);
+ const rates = this.calculateBandwidthRates(currentStats, this.lastNetStats);
+
+ if (rates) {
+ this.renderBandwidthRates(rates);
+ this.updateBandwidthHistory(rates.rxRate, rates.txRate);
this.updateBandwidthGraph();
}
- this.lastNetStats = current;
+
+ this.lastNetStats = currentStats;
} catch (err) {
console.error('updateNetworkStats error:', err);
}
}
- async updateWANStatus() {
+ async fetchConntrackUsage() {
+ const countRes = await this.core.ubusCall('file', 'read', {
+ path: '/proc/sys/net/netfilter/nf_conntrack_count'
+ });
+ const maxRes = await this.core.ubusCall('file', 'read', {
+ path: '/proc/sys/net/netfilter/nf_conntrack_max'
+ });
+
+ const [countStatus, countResult] = countRes;
+ const [maxStatus, maxResult] = maxRes;
+ if (countStatus !== 0 || maxStatus !== 0) {
+ throw new Error('Failed to fetch conntrack values');
+ }
+
+ const count = Number(String(countResult?.data || '').trim()) || 0;
+ const max = Number(String(maxResult?.data || '').trim()) || 0;
+ if (max <= 0) {
+ throw new Error('Invalid nf_conntrack_max');
+ }
+
+ const pct = Math.min(100, Math.max(0, (count / max) * 100));
+ return { count, max, pct };
+ }
+
+ renderConntrackUsage(stats) {
+ const usageEl = document.getElementById('conntrack-usage');
+ const detailEl = document.getElementById('conntrack-detail');
+ const barEl = document.getElementById('conntrack-bar');
+
+ if (!stats) {
+ if (usageEl) usageEl.textContent = 'N/A';
+ if (detailEl) detailEl.textContent = 'Conntrack data unavailable';
+ if (barEl) barEl.style.width = '0%';
+ return;
+ }
+
+ if (usageEl) usageEl.textContent = `${stats.pct.toFixed(1)}%`;
+ if (detailEl) detailEl.textContent = `${stats.count.toLocaleString()} / ${stats.max.toLocaleString()} tracked`;
+ if (barEl) barEl.style.width = `${stats.pct.toFixed(2)}%`;
+ }
+
+ async updateConntrackUsage() {
try {
- const [status, result] = await this.core.ubusCall('network.interface', 'dump', {});
- if (status !== 0 || !result?.interface) throw new Error('Failed');
- const interfaces = result.interface;
+ const stats = await this.fetchConntrackUsage();
+ this.renderConntrackUsage(stats);
+ } catch (err) {
+ this.renderConntrackUsage(null);
+ }
+ }
- let lanIface = interfaces.find(i => i.interface === 'lan' || i.device === 'br-lan');
- if (!lanIface) {
- lanIface = interfaces.find(i => i.up && i['ipv4-address']?.length > 0 && i.interface !== 'loopback');
- }
+ async fetchWANInterfaces() {
+ const [status, result] = await this.core.ubusCall('network.interface', 'dump', {});
+ if (status !== 0 || !result?.interface) {
+ throw new Error('Failed to fetch WAN interfaces');
+ }
+ return result.interface;
+ }
- let internetIface = null,
- gateway = null;
- for (const iface of interfaces) {
- if (!iface.up || iface.interface === 'loopback') continue;
- const defaultRoute = iface.route?.find(r => r.target === '0.0.0.0');
+ parseWANStatus(interfaces) {
+ let lanIface = interfaces.find(i => i.interface === 'lan' || i.device === 'br-lan');
+ if (!lanIface) {
+ lanIface = interfaces.find(
+ i => i.up && i['ipv4-address'] && i['ipv4-address'].length > 0 && i.interface !== 'loopback'
+ );
+ }
+
+ let internetIface = null;
+ let gateway = null;
+
+ for (const iface of interfaces) {
+ if (!iface.up || iface.interface === 'loopback') continue;
+ if (iface.route) {
+ const defaultRoute = iface.route.find(r => r.target === '0.0.0.0');
if (defaultRoute) {
internetIface = iface;
gateway = defaultRoute.nexthop;
break;
}
}
-
- this.renderWANStatus({ lanIface, internetIface, gateway });
- } catch (err) {
- console.error('Failed to load WAN status:', err);
- this.renderWANStatus(null);
}
+
+ return { lanIface, internetIface, gateway };
}
renderWANStatus(wanStatus) {
@@ -179,14 +386,18 @@ export default class DashboardModule {
const { lanIface, internetIface, gateway } = wanStatus;
- lanIpEl.textContent = lanIface?.['ipv4-address']?.[0]?.address || '---.---.---.---';
+ if (lanIface && lanIface['ipv4-address'] && lanIface['ipv4-address'][0]) {
+ lanIpEl.textContent = lanIface['ipv4-address'][0].address;
+ } else {
+ lanIpEl.textContent = '---.---.---.---';
+ }
if (internetIface) {
heroCard.classList.add('online');
heroCard.classList.remove('offline');
wanStatusEl.textContent = 'ONLINE';
- if (internetIface['ipv4-address']?.[0]) {
+ if (internetIface['ipv4-address'] && internetIface['ipv4-address'][0]) {
wanIpEl.textContent = internetIface['ipv4-address'][0].address;
} else if (gateway) {
wanIpEl.textContent = `Gateway: ${gateway}`;
@@ -201,135 +412,333 @@ export default class DashboardModule {
}
}
- async updateSystemLog() {
+ async updateWANStatus() {
+ try {
+ const interfaces = await this.fetchWANInterfaces();
+ const wanStatus = this.parseWANStatus(interfaces);
+ this.renderWANStatus(wanStatus);
+ } catch (err) {
+ console.error('Failed to load WAN status:', err);
+ this.renderWANStatus(null);
+ }
+ }
+
+ async fetchSystemLog() {
try {
const [status, result] = await this.core.ubusCall('file', 'exec', {
- command: '/usr/libexec/syslog-wrapper',
- params: []
+ command: '/bin/sh',
+ params: ['-c', 'logread 2>/dev/null']
});
- if (status !== 0 || !result?.stdout) throw new Error('Failed');
-
- const lines = result.stdout
- .split('\n')
- .filter(l => l.trim())
- .slice(-20);
- const logEl = document.getElementById('system-log');
- if (!logEl) return;
-
- if (lines.length === 0) {
- logEl.innerHTML = '
+ ${this.core.escapeHtml(item.name)}
+ ${this.core.escapeHtml(String(item.count))}
+ `
+ )
+ .join('')
+ : `No application data for this device `;
+
+ return `
+ ${this.core.escapeHtml(this.formatTime(row.ts))}
+ ${this.core.escapeHtml(row.target || this.target)}
+ ${this.core.escapeHtml(latency)}
+ ${this.getStatusBadge(row.status)}
+ `;
+ })
+ .join('');
+ }
+
+ renderSpeedtestPanel() {
+ const all = Array.isArray(this.speedtestSamples) ? this.speedtestSamples : [];
+ const valid = all.filter(s => s.download != null && s.upload != null).sort((a, b) => a.ts - b.ts);
+ const latestAny = all.length > 0 ? [...all].sort((a, b) => b.ts - a.ts)[0] : null;
+ const latestValid = valid.length > 0 ? valid[valid.length - 1] : null;
+
+ const downloadEl = document.getElementById('monitoring-speedtest-download');
+ const uploadEl = document.getElementById('monitoring-speedtest-upload');
+ const lastRunEl = document.getElementById('monitoring-speedtest-last-run');
+ if (downloadEl) downloadEl.textContent = latestValid ? `${latestValid.download.toFixed(1)} Mbps` : 'N/A';
+ if (uploadEl) uploadEl.textContent = latestValid ? `${latestValid.upload.toFixed(1)} Mbps` : 'N/A';
+ if (lastRunEl) lastRunEl.textContent = latestAny ? this.formatDateTime(latestAny.ts) : 'Never';
+
+ this.renderSpeedtestChart(valid);
+ this.renderSpeedtestTable(all);
+ }
+
+ renderSpeedtestChart(validRows = []) {
+ const svg = document.getElementById('monitoring-speedtest-chart');
+ const labels = document.getElementById('monitoring-speedtest-labels');
+ if (!svg || !labels) return;
+ const palette = this.getSpeedtestPalette();
+ const legendText = this.isColorfulGraphsEnabled()
+ ? 'Download (blue) / Upload (orange)'
+ : 'Download / Upload';
+
+ if (!Array.isArray(validRows) || validRows.length === 0) {
+ svg.innerHTML = 'No speedtest data yet ';
+ labels.innerHTML = '';
+ return;
+ }
+
+ const dailyMap = new Map();
+ for (const row of validRows) {
+ const key = this.dayKey(row.ts);
+ dailyMap.set(key, row);
+ }
+ const points = Array.from(dailyMap.values()).slice(-14);
+ const width = 860;
+ const height = 240;
+ const padLeft = 42;
+ const padRight = 14;
+ const padTop = 14;
+ const padBottom = 34;
+ const innerW = width - padLeft - padRight;
+ const innerH = height - padTop - padBottom;
+ const maxVal = Math.max(10, ...points.map(p => Math.max(p.download || 0, p.upload || 0)));
+
+ const makeX = index => {
+ if (points.length === 1) return padLeft + innerW / 2;
+ return padLeft + (innerW * index) / (points.length - 1);
+ };
+ const makeY = value => padTop + innerH - (Math.max(0, value) / maxVal) * innerH;
+
+ const downloadPath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${makeX(i)} ${makeY(p.download || 0)}`).join(' ');
+ const uploadPath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${makeX(i)} ${makeY(p.upload || 0)}`).join(' ');
+
+ const grid = [0.25, 0.5, 0.75].map(step => {
+ const y = padTop + innerH * step;
+ return ` `;
+ });
+
+ const circles = points
+ .map((p, i) => {
+ const x = makeX(i);
+ const yd = makeY(p.download || 0);
+ const yu = makeY(p.upload || 0);
+ const tipD = `${this.formatDate(p.ts)} download ${p.download.toFixed(1)} Mbps`;
+ const tipU = `${this.formatDate(p.ts)} upload ${p.upload.toFixed(1)} Mbps`;
+ return `
+ ${this.core.escapeHtml(tipD)}
+ ${this.core.escapeHtml(tipU)}
+ `;
+ })
+ .join('');
+
+ svg.innerHTML = `
+
+ ${grid.join('')}
+
+
+ ${circles}
+ ${legendText}
+ `;
+
+ labels.innerHTML = points.map(p => `${this.core.escapeHtml(this.formatDate(p.ts, true))}`).join('');
+ }
+
+ renderSpeedtestTable(rows = []) {
+ const tbody = document.querySelector('#monitoring-speedtest-table tbody');
+ if (!tbody) return;
+ const list = [...rows].sort((a, b) => b.ts - a.ts).slice(0, 12);
+ if (list.length === 0) {
+ this.core.renderEmptyTable(tbody, 4, 'No speedtest samples yet');
+ return;
+ }
+
+ tbody.innerHTML = list
+ .map(row => {
+ const download = row.download != null ? `${row.download.toFixed(1)} Mbps` : 'N/A';
+ const upload = row.upload != null ? `${row.upload.toFixed(1)} Mbps` : 'N/A';
+ const statusBadge = row.status === 'OK' ? this.core.renderBadge('success', 'ok') : this.core.renderBadge('error', 'error');
+ return `
+ ${this.core.escapeHtml(this.formatDateTime(row.ts))}
+ ${this.core.escapeHtml(download)}
+ ${this.core.escapeHtml(upload)}
+ ${statusBadge}
+ `;
+ })
+ .join('');
+ }
+
+ formatTime(ts, short = false) {
+ const d = new Date(ts);
+ return short
+ ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
+ : d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' });
+ }
+
+ formatDateTime(ts) {
+ const d = new Date(ts);
+ return d.toLocaleString([], {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ }
+
+ formatDate(ts, short = false) {
+ const d = new Date(ts);
+ if (short) {
+ return d.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
+ }
+ return d.toLocaleDateString([], {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ });
+ }
+
+ dayKey(ts) {
+ const d = new Date(ts);
+ 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}`;
+ }
+
+ formatTimeValue(hour, minute) {
+ return `${String(this.clampInt(hour, 0, 0, 23)).padStart(2, '0')}:${String(this.clampInt(minute, 0, 0, 59)).padStart(2, '0')}`;
+ }
+
+ parseTimeValue(value) {
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(value || '').trim());
+ if (!match) return null;
+ return [Number(match[1]), Number(match[2])];
+ }
+
+ clampInt(value, fallback, min, max) {
+ const n = Number(value);
+ if (!Number.isFinite(n)) return fallback;
+ const rounded = Math.round(n);
+ if (rounded < min) return min;
+ if (rounded > max) return max;
+ return rounded;
+ }
+
+ async exec(command, params = [], options = {}) {
+ const [status, result] = await this.core.ubusCall('file', 'exec', { command, params }, options);
+ if (status !== 0) {
+ throw new Error(`${command} failed (${status})`);
+ }
+ return result || {};
+ }
+}
diff --git a/moci/js/modules/netify.js b/moci/js/modules/netify.js
new file mode 100644
index 0000000..99abb06
--- /dev/null
+++ b/moci/js/modules/netify.js
@@ -0,0 +1,1125 @@
+export default class NetifyModule {
+ constructor(core) {
+ this.core = core;
+ this.initialized = false;
+ this.pollInterval = null;
+ this.outputPath = '/tmp/moci-netify.sqlite';
+ this.maxLines = 500000;
+ this.isRefreshing = false;
+ this.flows = [];
+ this.flowSearchQuery = '';
+ this.flowProtocolFilters = [];
+ this.flowsPage = 0;
+ this.flowsPageSize = 50;
+ this.hostnameByMac = new Map();
+ this.hostnameByIp = new Map();
+ this.lastHostRefreshAt = 0;
+ this.visibleFlows = [];
+ this.topAppsPage = 0;
+ this.topAppsPageSize = 5;
+ this.topAppsRows = [];
+ this.debugLog = [];
+ this.debugMax = 120;
+ this.lastFlowCount = -1;
+ this.sqlChunkSize = 200;
+ this.sqlChunkCalls = 100;
+ this.lastLoadedLimit = 0;
+ this.loadedOffset = 0;
+ this.hasMoreFlows = true;
+ this.isLoadingMore = false;
+ this.totalFlowCount = 0;
+ this.currentMaxPage = 0;
+ this.pauseAutoRefresh = false;
+ this.userPausedAutoRefresh = false;
+ this.isRefreshingCards = false;
+ this.lastCardsRefreshAt = 0;
+ this.cardsRefreshIntervalMs = 10000;
+ this.lastTopAppsRefreshAt = 0;
+ this.isRefreshingTopApps = false;
+
+ this.core.registerRoute('/netify', async () => {
+ const pageElement = document.getElementById('netify-page');
+ if (pageElement) pageElement.classList.remove('hidden');
+
+ if (!this.initialized) {
+ this.setupHandlers();
+ this.initialized = true;
+ }
+
+ await this.load();
+ });
+ }
+
+ setupHandlers() {
+ document.getElementById('netify-refresh-btn')?.addEventListener('click', () => this.refresh(true, true));
+ document.getElementById('netify-start-btn')?.addEventListener('click', () => this.runServiceAction('start'));
+ document.getElementById('netify-stop-btn')?.addEventListener('click', () => this.runServiceAction('stop'));
+ document.getElementById('netify-restart-btn')?.addEventListener('click', () => this.runServiceAction('restart'));
+ document.getElementById('netify-init-db-btn')?.addEventListener('click', () => this.initCollectorOutput());
+ document.getElementById('netify-full-reset-btn')?.addEventListener('click', () => this.fullResetCollector());
+ document.getElementById('netify-debug-clear-btn')?.addEventListener('click', () => this.clearDebugLog());
+ document.getElementById('netify-collector-toggle-btn')?.addEventListener('click', () => this.toggleCollectorPanel());
+ document.getElementById('netify-auto-refresh-toggle-btn')?.addEventListener('click', () =>
+ this.toggleAutoRefreshPause()
+ );
+ document.getElementById('netify-flow-search')?.addEventListener('input', event => {
+ this.flowSearchQuery = String(event?.target?.value || '')
+ .trim()
+ .toLowerCase();
+ this.flowsPage = 0;
+ this.pauseAutoRefresh = false;
+ this.renderRecentFlows();
+ });
+ document.getElementById('netify-flow-protocol-filter')?.addEventListener('input', event => {
+ this.flowProtocolFilters = this.parseProtocolFilters(event?.target?.value || '');
+ this.flowsPage = 0;
+ this.pauseAutoRefresh = false;
+ this.renderRecentFlows();
+ });
+ document.getElementById('netify-flows-prev-btn')?.addEventListener('click', () => {
+ this.flowsPage = Math.max(0, this.flowsPage - 1);
+ this.pauseAutoRefresh = this.flowsPage > 0;
+ this.renderRecentFlows();
+ });
+ document.getElementById('netify-flows-next-btn')?.addEventListener('click', async () => {
+ if (this.flowsPage >= this.currentMaxPage) {
+ const loaded = await this.loadMoreFlows();
+ if (loaded) this.flowsPage += 1;
+ } else {
+ this.flowsPage += 1;
+ }
+ this.pauseAutoRefresh = this.flowsPage > 0;
+ this.renderRecentFlows();
+ });
+ document.getElementById('netify-action-type')?.addEventListener('change', () => this.syncActionTypeUi());
+ document.getElementById('netify-top-apps-prev-btn')?.addEventListener('click', () => {
+ this.topAppsPage = Math.max(0, this.topAppsPage - 1);
+ this.renderTopApps();
+ });
+ document.getElementById('netify-top-apps-next-btn')?.addEventListener('click', () => {
+ this.topAppsPage += 1;
+ this.renderTopApps();
+ });
+ document.getElementById('save-netify-flow-action-btn')?.addEventListener('click', () => this.saveFlowAction());
+ document.getElementById('cancel-netify-flow-action-btn')?.addEventListener('click', () =>
+ this.core.closeModal('netify-flow-action-modal')
+ );
+ document
+ .getElementById('close-netify-flow-action-modal')
+ ?.addEventListener('click', () => this.core.closeModal('netify-flow-action-modal'));
+ document.querySelector('#netify-flows-table tbody')?.addEventListener('click', event => this.handleFlowRowClick(event));
+ this.syncCollectorPanel();
+ this.updateAutoRefreshToggleUi();
+ this.renderDebugLog();
+ }
+
+ toggleCollectorPanel() {
+ const body = document.getElementById('netify-collector-body');
+ const icon = document.getElementById('netify-collector-toggle-icon');
+ const btn = document.getElementById('netify-collector-toggle-btn');
+ if (!body || !icon || !btn) return;
+
+ const isHidden = body.style.display === 'none' || body.style.display === '';
+ if (isHidden) {
+ body.style.display = 'block';
+ icon.textContent = '▾';
+ btn.setAttribute('aria-expanded', 'true');
+ localStorage.setItem('netify_collector_expanded', '1');
+ } else {
+ body.style.display = 'none';
+ icon.textContent = '▸';
+ btn.setAttribute('aria-expanded', 'false');
+ localStorage.setItem('netify_collector_expanded', '0');
+ }
+ }
+
+ syncCollectorPanel() {
+ const body = document.getElementById('netify-collector-body');
+ const icon = document.getElementById('netify-collector-toggle-icon');
+ const btn = document.getElementById('netify-collector-toggle-btn');
+ if (!body || !icon || !btn) return;
+
+ const expanded = localStorage.getItem('netify_collector_expanded') === '1';
+ if (expanded) {
+ body.style.display = 'block';
+ icon.textContent = '▾';
+ btn.setAttribute('aria-expanded', 'true');
+ } else {
+ body.style.display = 'none';
+ icon.textContent = '▸';
+ btn.setAttribute('aria-expanded', 'false');
+ }
+ }
+
+ async load() {
+ this.logDebug(`Netify page load; db=${this.outputPath}`);
+ this.userPausedAutoRefresh = true;
+ this.updateAutoRefreshToggleUi();
+ await this.loadConfig();
+ this.syncCollectorPanel();
+ this.startPolling();
+ await this.refresh(false, true);
+ }
+
+ startPolling() {
+ if (this.pollInterval) return;
+ this.pollInterval = setInterval(() => {
+ // Preserve user position while paging historical rows.
+ // Auto-refresh only when on page 1 (index 0).
+ if (this.core.currentRoute && this.core.currentRoute.startsWith('/netify') && !this.isAutoRefreshPaused()) {
+ this.refresh(false, false);
+ }
+ if (this.core.currentRoute && this.core.currentRoute.startsWith('/netify')) {
+ const now = Date.now();
+ if (now - this.lastCardsRefreshAt >= this.cardsRefreshIntervalMs) {
+ this.refreshCardsAuto();
+ }
+ if (now - this.lastTopAppsRefreshAt >= 60000) {
+ this.refreshTopAppsAuto();
+ }
+ }
+ }, 10000);
+ }
+
+ isAutoRefreshPaused() {
+ return this.userPausedAutoRefresh || this.pauseAutoRefresh || this.flowsPage > 0;
+ }
+
+ toggleAutoRefreshPause() {
+ this.userPausedAutoRefresh = !this.userPausedAutoRefresh;
+ this.updateAutoRefreshToggleUi();
+ this.logDebug(this.userPausedAutoRefresh ? 'Auto-refresh paused by user' : 'Auto-refresh resumed by user');
+ }
+
+ updateAutoRefreshToggleUi() {
+ const btn = document.getElementById('netify-auto-refresh-toggle-btn');
+ if (!btn) return;
+ btn.classList.remove('danger', 'success');
+ if (this.userPausedAutoRefresh) {
+ btn.textContent = 'RESUME';
+ btn.setAttribute('aria-pressed', 'true');
+ btn.classList.add('danger');
+ } else {
+ btn.textContent = 'PAUSE';
+ btn.setAttribute('aria-pressed', 'false');
+ btn.classList.add('success');
+ }
+ }
+
+ async loadConfig() {
+ try {
+ const [status, result] = await this.core.uciGet('moci', 'collector');
+ if (status === 0 && result?.values) {
+ const c = result.values;
+ const configuredDbPath = String(c.db_path || '').trim();
+ const configuredOutput = String(c.output_file || '').trim();
+ if (configuredDbPath) {
+ this.outputPath = configuredDbPath;
+ } else if (configuredOutput && /\.sqlite(?:3)?$/i.test(configuredOutput)) {
+ this.outputPath = configuredOutput;
+ }
+ this.maxLines = Number(c.retention_rows || c.max_lines) || this.maxLines;
+ }
+ } catch {}
+
+ const pathEl = document.getElementById('netify-db-path');
+ if (pathEl) pathEl.textContent = this.outputPath;
+ this.logDebug(`Config loaded; db=${this.outputPath} retention=${this.maxLines}`);
+ }
+
+ async runServiceAction(action) {
+ try {
+ await this.exec('/etc/init.d/netify-collector', [action]);
+ this.core.showToast(`Netify collector ${action}ed`, 'success');
+ setTimeout(() => this.refresh(false), 600);
+ } catch (err) {
+ console.error(`Failed to ${action} netify collector:`, err);
+ this.logDebug(`Collector ${action} failed: ${err?.message || 'unknown error'}`);
+ this.core.showToast(this.describeExecFailure(err, `Failed to ${action} collector`), 'error');
+ }
+ }
+
+ async initCollectorOutput() {
+ try {
+ await this.exec('/usr/bin/moci-netify-collector', ['--init-db']);
+ this.core.showToast('Netify database initialized', 'success');
+ await this.refresh(false);
+ } catch (err) {
+ console.error('Failed to initialize Netify output file:', err);
+ this.logDebug(`Init DB failed: ${err?.message || 'unknown error'}`);
+ this.core.showToast(this.describeExecFailure(err, 'Failed to initialize database'), 'error');
+ }
+ }
+
+ async fullResetCollector() {
+ try {
+ const cmd = `
+/etc/init.d/netify-collector stop || true
+killall moci-netify-collector 2>/dev/null || true
+pkill -f "/usr/bin/moci-netify-collector" 2>/dev/null || true
+rm -f ${this.shellQuote(this.outputPath)}
+/etc/init.d/netify-collector start
+pgrep -fa moci-netify-collector || true
+`;
+ const result = await this.exec('/bin/sh', ['-c', cmd], { timeout: 30000 });
+ const running = String(result?.stdout || '')
+ .trim()
+ .split('\n')
+ .filter(Boolean).length;
+ this.core.showToast(`Netify full reset complete (${running} process entries)`, 'success');
+ await this.refresh(false);
+ } catch (err) {
+ console.error('Failed Netify full reset:', err);
+ this.logDebug(`Full reset failed: ${err?.message || 'unknown error'}`);
+ this.core.showToast(this.describeExecFailure(err, 'Failed full reset'), 'error');
+ }
+ }
+
+ async refresh(showErrorToast = true, refreshTopApps = true) {
+ if (this.isRefreshing) return;
+ this.isRefreshing = true;
+
+ try {
+ await this.updateStatus();
+ await this.loadFlowFile(true);
+ await this.loadFlowTotalCount();
+ await this.refreshHostnameMap();
+ this.renderOverview();
+ this.lastCardsRefreshAt = Date.now();
+ if (refreshTopApps) {
+ this.recomputeTopAppsRows(this.flows);
+ this.renderTopApps();
+ this.lastTopAppsRefreshAt = Date.now();
+ }
+ this.renderRecentFlows();
+ } catch (err) {
+ console.error('Failed to refresh Netify view:', err);
+ this.logDebug(`Refresh failed: ${err?.message || 'unknown error'}`);
+ if (showErrorToast) this.core.showToast('Failed to refresh Netify data', 'error');
+ } finally {
+ this.isRefreshing = false;
+ }
+ }
+
+ async refreshCardsAuto() {
+ if (this.isRefreshing || this.isRefreshingCards) return;
+ this.isRefreshingCards = true;
+ try {
+ const configuredLimit = Math.min(Math.max(Number(this.maxLines) || 5000, 50), 20000);
+ const maxWindowRows = Math.max(20, this.sqlChunkSize * this.sqlChunkCalls);
+ const limit = Math.max(20, Math.min(configuredLimit, maxWindowRows));
+ const snapshot = await this.fetchFlowChunkWindow(limit, 0);
+ const totalCount = await this.queryFlowTotalCount();
+ this.renderOverview(snapshot, totalCount);
+ this.lastCardsRefreshAt = Date.now();
+ } catch (err) {
+ this.logDebug(`Card auto-refresh failed: ${err?.message || 'unknown error'}`);
+ } finally {
+ this.isRefreshingCards = false;
+ }
+ }
+
+ async refreshTopAppsAuto() {
+ if (this.isRefreshing || this.isRefreshingTopApps) return;
+ this.isRefreshingTopApps = true;
+ try {
+ const configuredLimit = Math.min(Math.max(Number(this.maxLines) || 5000, 50), 20000);
+ const maxWindowRows = Math.max(20, this.sqlChunkSize * this.sqlChunkCalls);
+ const limit = Math.max(20, Math.min(configuredLimit, maxWindowRows));
+ const snapshot = await this.fetchFlowChunkWindow(limit, 0);
+ this.recomputeTopAppsRows(snapshot);
+ this.renderTopApps();
+ this.lastTopAppsRefreshAt = Date.now();
+ this.logDebug(`Top apps auto-refreshed from ${snapshot.length} sampled row(s)`);
+ } catch (err) {
+ this.logDebug(`Top apps auto-refresh failed: ${err?.message || 'unknown error'}`);
+ } finally {
+ this.isRefreshingTopApps = false;
+ }
+ }
+
+ async updateStatus() {
+ const statusEl = document.getElementById('netify-service-status');
+ const fileStatusEl = document.getElementById('netify-db-status');
+ if (!statusEl || !fileStatusEl) return;
+
+ try {
+ const running = await this.execShell('pgrep -f moci-netify-collector >/dev/null && echo RUNNING || echo STOPPED');
+ const serviceUp = (running.stdout || '').trim() === 'RUNNING';
+ statusEl.innerHTML = serviceUp
+ ? this.core.renderBadge('success', 'RUNNING')
+ : this.core.renderBadge('error', 'STOPPED');
+ } catch {
+ statusEl.innerHTML = this.core.renderBadge('error', 'UNKNOWN');
+ }
+
+ try {
+ const checkFile = await this.execShell(`[ -f ${this.shellQuote(this.outputPath)} ] && echo PRESENT || echo MISSING`);
+ const filePresent = (checkFile.stdout || '').trim() === 'PRESENT';
+ fileStatusEl.innerHTML = filePresent
+ ? this.core.renderBadge('success', 'READY')
+ : this.core.renderBadge('error', 'MISSING');
+ } catch {
+ fileStatusEl.innerHTML = this.core.renderBadge('error', 'UNKNOWN');
+ }
+ }
+
+ async loadFlowTotalCount() {
+ try {
+ this.totalFlowCount = await this.queryFlowTotalCount();
+ } catch (err) {
+ this.totalFlowCount = Number(this.flows.length) || 0;
+ this.logDebug(`Failed to load total flow count: ${err?.message || 'unknown error'}`);
+ }
+ }
+
+ async queryFlowTotalCount() {
+ const out = await this.querySql('SELECT COUNT(*) FROM flow_raw;');
+ const lines = String(out || '')
+ .trim()
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean);
+ const numericLine = [...lines].reverse().find(line => /^\d+$/.test(line)) || '0';
+ const count = Number(numericLine);
+ return Number.isFinite(count) && count >= 0 ? count : 0;
+ }
+
+ async loadFlowFile(reset = false) {
+ try {
+ if (reset) {
+ this.flows = [];
+ this.loadedOffset = 0;
+ this.hasMoreFlows = true;
+ }
+ if (!this.hasMoreFlows) return false;
+
+ const configuredLimit = Math.min(Math.max(Number(this.maxLines) || 5000, 50), 20000);
+ if (this.loadedOffset >= configuredLimit) {
+ this.hasMoreFlows = false;
+ return false;
+ }
+
+ const remainingCap = configuredLimit - this.loadedOffset;
+ const maxWindowRows = Math.max(20, this.sqlChunkSize * this.sqlChunkCalls);
+ const requested = Math.max(20, Math.min(maxWindowRows, remainingCap));
+ const tried = new Set();
+ const limits = [requested, 150, 100, 80, 60].filter(n => {
+ if (n < 20 || tried.has(n)) return false;
+ tried.add(n);
+ return true;
+ });
+
+ let loaded = false;
+ let lastErr = null;
+ for (const limit of limits) {
+ try {
+ const chunk = await this.fetchFlowChunkWindow(limit, this.loadedOffset);
+ if (chunk.length === 0) {
+ this.hasMoreFlows = false;
+ if (this.flows.length === 0) {
+ if (this.lastFlowCount !== 0) this.logDebug('SQL query returned 0 rows');
+ this.lastFlowCount = 0;
+ }
+ return false;
+ }
+ this.flows = this.flows.concat(chunk);
+ this.loadedOffset += chunk.length;
+ this.lastLoadedLimit = chunk.length;
+ if (this.flows.length !== this.lastFlowCount) {
+ this.logDebug(
+ `Loaded ${chunk.length} more row(s), total loaded=${this.flows.length} (requested=${limit}, offset=${this.loadedOffset})`
+ );
+ this.lastFlowCount = this.flows.length;
+ }
+ const knownTotal = Number(this.totalFlowCount) || 0;
+ this.hasMoreFlows = knownTotal > this.loadedOffset && this.loadedOffset < configuredLimit;
+ loaded = true;
+ break;
+ } catch (err) {
+ lastErr = err;
+ this.logDebug(`Flow load failed at limit=${limit}, trying smaller batch`);
+ }
+ }
+
+ if (!loaded) {
+ throw lastErr || new Error('all loadFlowFile attempts failed');
+ }
+ return true;
+ } catch {
+ if (reset) {
+ this.flows = [];
+ this.lastFlowCount = 0;
+ this.lastLoadedLimit = 0;
+ this.loadedOffset = 0;
+ }
+ this.logDebug('Failed to load flow rows from sqlite');
+ return false;
+ }
+ }
+
+ async fetchFlowChunkWindow(limit, startOffset) {
+ const maxStep = Math.max(20, Number(this.sqlChunkSize) || 200);
+ const maxCalls = Math.max(1, Number(this.sqlChunkCalls) || 15);
+ let remaining = Math.max(0, Number(limit) || 0);
+ let offset = Math.max(0, Number(startOffset) || 0);
+ let combined = [];
+ let calls = 0;
+
+ while (remaining > 0 && calls < maxCalls) {
+ const step = Math.min(maxStep, remaining);
+ const sql = `SELECT json FROM flow_raw ORDER BY id DESC LIMIT ${step} OFFSET ${offset};`;
+ const out = await this.querySql(sql);
+ const data = String(out || '').trim();
+ if (!data) break;
+
+ const parsed = this.parseFlowJsonl(data);
+ if (parsed.length === 0) break;
+
+ combined = combined.concat(parsed);
+ offset += parsed.length;
+ remaining -= parsed.length;
+ calls += 1;
+
+ // Reached end of available rows for this window.
+ if (parsed.length < step) break;
+ }
+
+ return combined;
+ }
+
+ async loadMoreFlows() {
+ if (this.isLoadingMore) return false;
+ if (!this.hasMoreFlows) return false;
+ this.isLoadingMore = true;
+ try {
+ return await this.loadFlowFile(false);
+ } finally {
+ this.isLoadingMore = false;
+ }
+ }
+
+ async querySql(sql) {
+ const statement = `PRAGMA busy_timeout=3000; ${sql}`;
+ const db = this.shellQuote(this.outputPath);
+ const sqlQuoted = this.shellQuote(statement);
+ const shellCmd = `if command -v sqlite3 >/dev/null 2>&1; then sqlite3 ${db} ${sqlQuoted}; elif command -v sqlite3-cli >/dev/null 2>&1; then sqlite3-cli ${db} ${sqlQuoted}; else echo "sqlite3 not installed" >&2; exit 127; fi`;
+ let lastErr = null;
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ const result = await this.exec('/bin/sh', ['-c', shellCmd], { timeout: 12000 });
+ return String(result?.stdout || '');
+ } catch (err) {
+ lastErr = err;
+ this.logDebug(`SQLite query attempt failed (shell): ${err?.message || 'unknown error'}`);
+ if (attempt === 0) {
+ await new Promise(resolve => setTimeout(resolve, 250));
+ }
+ }
+ }
+ throw lastErr || new Error('sqlite command failed');
+ }
+
+ logDebug(message) {
+ const ts = new Date().toLocaleTimeString([], { hour12: false });
+ const entry = `[${ts}] ${String(message || '')}`;
+ this.debugLog.push(entry);
+ if (this.debugLog.length > this.debugMax) {
+ this.debugLog = this.debugLog.slice(-this.debugMax);
+ }
+ this.renderDebugLog();
+ }
+
+ clearDebugLog() {
+ this.debugLog = [];
+ this.renderDebugLog();
+ this.logDebug('Debug log cleared');
+ }
+
+ renderDebugLog() {
+ const el = document.getElementById('netify-debug-log');
+ if (!el) return;
+ if (this.debugLog.length === 0) {
+ el.textContent = 'No events yet.';
+ return;
+ }
+ el.textContent = this.debugLog.join('\n');
+ el.scrollTop = el.scrollHeight;
+ }
+
+ parseFlowJsonl(content) {
+ return (content || '')
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean)
+ .map(line => {
+ let parsed;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ return null;
+ }
+ if (!parsed || parsed.type !== 'flow' || !parsed.flow) return null;
+
+ const flow = parsed.flow;
+ const tsRaw = flow.last_seen_at || flow.first_seen_at || Date.now();
+ const tsMs = Number(tsRaw) > 1e12 ? Number(tsRaw) : Number(tsRaw) * 1000;
+ const ts = Number.isFinite(tsMs) && tsMs > 0 ? tsMs : Date.now();
+
+ const app =
+ flow.detected_application_name ||
+ flow.detected_app_name ||
+ flow.host_server_name ||
+ flow.dns_host_name ||
+ flow.ssl?.client_sni ||
+ flow.other_ip ||
+ 'Unknown';
+ const fqdn =
+ flow.host_server_name ||
+ flow.fqdn ||
+ flow.dns_host_name ||
+ flow.ssl?.client_sni ||
+ '';
+
+ const proto = flow.detected_protocol_name || 'N/A';
+ const device = this.normalizeMac(flow.local_mac) || 'unknown';
+ const localIp = flow.local_ip || '-';
+ const destIp = flow.other_ip || '-';
+ const destPort = flow.other_port || 0;
+ const bytes =
+ Number(flow.total_bytes || 0) ||
+ Number(flow.other_bytes || 0) ||
+ Number(flow.local_bytes || 0) ||
+ 0;
+
+ return {
+ ts,
+ timeLabel: this.formatTimestamp(ts),
+ device,
+ localIp,
+ app,
+ fqdn,
+ proto,
+ destIp,
+ destPort,
+ bytes
+ };
+ })
+ .filter(Boolean)
+ .slice(-this.maxLines);
+ }
+
+ async refreshHostnameMap() {
+ const now = Date.now();
+ if (now - this.lastHostRefreshAt < 15000 && this.hostnameByMac.size > 0) return;
+
+ const byMac = new Map();
+ const byIp = new Map();
+
+ try {
+ const [status, result] = await this.core.ubusCall('luci-rpc', 'getDHCPLeases', {});
+ if (status === 0 && Array.isArray(result?.dhcp_leases)) {
+ for (const lease of result.dhcp_leases) {
+ const hostname = String(lease.hostname || '').trim();
+ if (!hostname) continue;
+
+ const mac = this.normalizeMac(lease.macaddr);
+ const ip = String(lease.ipaddr || '').trim();
+
+ if (mac) byMac.set(mac, hostname);
+ if (ip) byIp.set(ip, hostname);
+ }
+ }
+ } catch {}
+
+ // Also resolve names from static DHCP host entries so user-defined names
+ // appear even when active lease hostname is empty.
+ try {
+ const [status, result] = await this.core.uciGet('dhcp');
+ if (status === 0 && result?.values) {
+ for (const [, cfg] of Object.entries(result.values)) {
+ if (cfg?.['.type'] !== 'host') continue;
+ const hostname = String(cfg.name || '').trim();
+ if (!hostname) continue;
+
+ const mac = this.normalizeMac(cfg.mac);
+ const ip = String(cfg.ip || '').trim();
+ if (mac && !byMac.has(mac)) byMac.set(mac, hostname);
+ if (ip && !byIp.has(ip)) byIp.set(ip, hostname);
+ }
+ }
+ } catch {}
+
+ this.hostnameByMac = byMac;
+ this.hostnameByIp = byIp;
+ this.lastHostRefreshAt = now;
+ }
+
+ resolveDeviceLabel(flow) {
+ const mac = this.normalizeMac(flow.device);
+ const ip = String(flow.localIp || '').trim();
+ const host = (mac && this.hostnameByMac.get(mac)) || (ip && this.hostnameByIp.get(ip)) || '';
+ return host || flow.device || 'unknown';
+ }
+
+ normalizeMac(value) {
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const parsed = this.normalizeMac(item);
+ if (parsed) return parsed;
+ }
+ return '';
+ }
+
+ const raw = String(value || '')
+ .trim()
+ .toLowerCase();
+ if (!raw) return '';
+ const first = raw.split(/[\s,]+/)[0];
+ return /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(first) ? first : '';
+ }
+
+ renderOverview(sourceFlows = this.flows, totalFlowCount = this.totalFlowCount) {
+ const flowCount = Number(totalFlowCount) > 0 ? totalFlowCount : sourceFlows.length;
+ const devices = new Set(sourceFlows.map(f => f.device).filter(v => v && v !== 'unknown'));
+ const apps = new Set(sourceFlows.map(f => f.app).filter(Boolean));
+ const totalBytes = sourceFlows.reduce((sum, f) => sum + (f.bytes || 0), 0);
+
+ document.getElementById('netify-flow-count').textContent = String(flowCount);
+ document.getElementById('netify-device-count').textContent = String(devices.size);
+ document.getElementById('netify-app-count').textContent = String(apps.size);
+ document.getElementById('netify-total-bytes').textContent = this.core.formatBytes(totalBytes);
+ }
+
+ recomputeTopAppsRows(sourceFlows = this.flows) {
+ const map = new Map();
+ for (const flow of sourceFlows || []) {
+ const key = flow.app || 'Unknown';
+ const current = map.get(key) || { app: key, flows: 0, lastTs: 0 };
+ current.flows += 1;
+ if (flow.ts > current.lastTs) current.lastTs = flow.ts;
+ map.set(key, current);
+ }
+
+ const rows = Array.from(map.values())
+ .sort((a, b) => b.flows - a.flows)
+ .slice(0, 50)
+ .map(item => ({
+ app: item.app,
+ flows: String(item.flows),
+ lastSeen: item.lastTs ? this.formatTimestamp(item.lastTs) : '-'
+ }));
+ this.topAppsRows = rows;
+ this.topAppsPage = 0;
+ }
+
+ renderTopApps() {
+ const tbody = document.querySelector('#netify-top-apps-table tbody');
+ if (!tbody) return;
+ const rows = Array.isArray(this.topAppsRows) ? this.topAppsRows : [];
+
+ if (rows.length === 0) {
+ this.core.renderEmptyTable(tbody, 3, 'No Netify flow data yet');
+ this.updateTopAppsPagination(0, 0, 0, 0);
+ return;
+ }
+
+ const total = rows.length;
+ const maxPage = total > 0 ? Math.max(0, Math.ceil(total / this.topAppsPageSize) - 1) : 0;
+ if (this.topAppsPage > maxPage) this.topAppsPage = maxPage;
+ const startIdx = this.topAppsPage * this.topAppsPageSize;
+ const endIdx = Math.min(total, startIdx + this.topAppsPageSize);
+ const pageRows = rows.slice(startIdx, endIdx);
+ this.updateTopAppsPagination(total, startIdx, endIdx, maxPage);
+
+ tbody.innerHTML = pageRows
+ .map(
+ row => `
+ ${this.core.escapeHtml(row.app)}
+ ${this.core.escapeHtml(row.flows)}
+ ${this.core.escapeHtml(row.lastSeen)}
+ `
+ )
+ .join('');
+ }
+
+ updateTopAppsPagination(total, startIdx, endIdx, maxPage) {
+ const infoEl = document.getElementById('netify-top-apps-page-info');
+ const prevBtn = document.getElementById('netify-top-apps-prev-btn');
+ const nextBtn = document.getElementById('netify-top-apps-next-btn');
+
+ if (infoEl) {
+ if (total <= 0) infoEl.textContent = '0-0 of 0';
+ else infoEl.textContent = `${startIdx + 1}-${endIdx} of ${total}`;
+ }
+ if (prevBtn) prevBtn.disabled = this.topAppsPage <= 0;
+ if (nextBtn) nextBtn.disabled = this.topAppsPage >= maxPage || total === 0;
+ }
+
+ renderRecentFlows() {
+ const tbody = document.querySelector('#netify-flows-table tbody');
+ if (!tbody) return;
+
+ let rows = [...this.flows]
+ .sort((a, b) => b.ts - a.ts);
+
+ const q = this.flowSearchQuery;
+ if (q) {
+ rows = rows.filter(row => {
+ const deviceLabel = this.resolveDeviceLabel(row);
+ const haystack = [
+ row.timeLabel,
+ deviceLabel,
+ row.device,
+ row.localIp,
+ row.app,
+ row.fqdn,
+ row.proto,
+ row.destIp,
+ String(row.destPort || '')
+ ]
+ .join(' ')
+ .toLowerCase();
+ return haystack.includes(q);
+ });
+ }
+ const protocolFilters = Array.isArray(this.flowProtocolFilters) ? this.flowProtocolFilters : [];
+ if (protocolFilters.length > 0) {
+ rows = rows.filter(row => {
+ const protocol = String(row.proto || '')
+ .trim()
+ .toLowerCase();
+ return protocolFilters.some(filter => protocol === filter || protocol.includes(filter));
+ });
+ }
+ const total = rows.length;
+ const maxPage = total > 0 ? Math.max(0, Math.ceil(total / this.flowsPageSize) - 1) : 0;
+ if (this.flowsPage > maxPage) this.flowsPage = maxPage;
+ this.pauseAutoRefresh = this.flowsPage > 0;
+ const startIdx = this.flowsPage * this.flowsPageSize;
+ const endIdx = Math.min(total, startIdx + this.flowsPageSize);
+ const pageRows = rows.slice(startIdx, endIdx);
+ this.visibleFlows = pageRows;
+ this.updateFlowPagination(total, startIdx, endIdx, maxPage);
+
+ if (pageRows.length === 0) {
+ this.core.renderEmptyTable(tbody, 8, this.flowSearchQuery ? 'No matching flows found' : 'No Netify flow data yet');
+ return;
+ }
+
+ tbody.innerHTML = pageRows
+ .map(
+ (row, idx) => `
+ ${this.core.escapeHtml(row.timeLabel)}
+ ${this.core.escapeHtml(this.resolveDeviceLabel(row))}
+ ${this.core.escapeHtml(row.localIp || '-')}
+
+
+ ${this.core.escapeHtml(row.fqdn || '-')}
+
+
+ ${this.core.escapeHtml(row.app)}
+ ${this.core.escapeHtml(row.proto)}
+ ${this.core.escapeHtml(row.destIp)}
+ ${this.core.escapeHtml(String(row.destPort || 0))}
+ `
+ )
+ .join('');
+ }
+
+ parseProtocolFilters(value) {
+ return String(value || '')
+ .split(',')
+ .map(item => item.trim().toLowerCase())
+ .filter(Boolean)
+ .filter((item, index, arr) => arr.indexOf(item) === index);
+ }
+
+ updateFlowPagination(total, startIdx, endIdx, maxPage) {
+ const infoEl = document.getElementById('netify-flows-page-info');
+ const prevBtn = document.getElementById('netify-flows-prev-btn');
+ const nextBtn = document.getElementById('netify-flows-next-btn');
+ this.currentMaxPage = maxPage;
+
+ if (infoEl) {
+ if (total <= 0) infoEl.textContent = '0-0 of 0';
+ else infoEl.textContent = `${startIdx + 1}-${endIdx} of ${total}`;
+ }
+ if (prevBtn) prevBtn.disabled = this.flowsPage <= 0;
+ if (nextBtn) nextBtn.disabled = this.flowsPage >= maxPage || total === 0;
+ }
+
+ handleFlowRowClick(event) {
+ const tr = event.target?.closest?.('tr[data-flow-index]');
+ if (!tr) return;
+ const idx = Number(tr.getAttribute('data-flow-index'));
+ if (!Number.isInteger(idx) || idx < 0 || idx >= this.visibleFlows.length) return;
+ this.openFlowActionModal(idx);
+ }
+
+ openFlowActionModal(index) {
+ const flow = this.visibleFlows[index];
+ if (!flow) return;
+
+ document.getElementById('netify-action-flow-index').value = String(index);
+ const domainInput = document.getElementById('netify-action-domain');
+ const resolvedDomain = this.sanitizeDomain(flow.fqdn || '');
+ if (domainInput) domainInput.value = resolvedDomain;
+ const domainScope = document.getElementById('netify-action-domain-scope');
+ if (domainScope) {
+ const root = this.extractRootDomain(resolvedDomain);
+ const hasSubdomain = root && root !== resolvedDomain;
+ domainScope.value = hasSubdomain ? 'full' : 'root';
+ if (domainScope.options?.length >= 2) {
+ domainScope.options[0].text = `THIS EXACT DOMAIN (${resolvedDomain || 'N/A'})`;
+ domainScope.options[1].text = `ROOT DOMAIN (${root || resolvedDomain || 'N/A'})`;
+ domainScope.options[1].disabled = !root;
+ }
+ }
+
+ const srcIpInput = document.getElementById('netify-action-source-ip');
+ if (srcIpInput) srcIpInput.value = flow.localIp || '';
+ const dstIpInput = document.getElementById('netify-action-dest-ip');
+ if (dstIpInput) dstIpInput.value = flow.destIp || '';
+
+ const scopeSelect = document.getElementById('netify-action-scope');
+ if (scopeSelect) {
+ scopeSelect.value = this.isValidIp(flow.localIp) ? 'source_dest' : 'all_sources';
+ }
+
+ const actionType = document.getElementById('netify-action-type');
+ if (actionType) {
+ actionType.value = domainInput?.value ? 'domain' : 'ip';
+ }
+ this.syncActionTypeUi();
+ this.core.openModal('netify-flow-action-modal');
+ }
+
+ syncActionTypeUi() {
+ const type = document.getElementById('netify-action-type')?.value || 'domain';
+ const domainGroup = document.getElementById('netify-domain-group');
+ const ipGroup = document.getElementById('netify-ip-block-group');
+ if (!domainGroup || !ipGroup) return;
+ const isDomain = type === 'domain';
+ domainGroup.classList.toggle('hidden', !isDomain);
+ ipGroup.classList.toggle('hidden', isDomain);
+ }
+
+ async saveFlowAction() {
+ this.setFlowActionBusy(true);
+ const index = Number(document.getElementById('netify-action-flow-index')?.value || -1);
+ if (!Number.isInteger(index) || index < 0 || index >= this.visibleFlows.length) {
+ this.core.showToast('Flow not found', 'error');
+ this.setFlowActionBusy(false);
+ return;
+ }
+ const flow = this.visibleFlows[index];
+ const type = document.getElementById('netify-action-type')?.value || 'domain';
+
+ try {
+ if (type === 'domain') {
+ const inputDomain = this.sanitizeDomain(document.getElementById('netify-action-domain')?.value || '');
+ if (!inputDomain) {
+ this.core.showToast('No valid domain found for this flow', 'error');
+ return;
+ }
+ const scope = document.getElementById('netify-action-domain-scope')?.value || 'full';
+ const rootDomain = this.extractRootDomain(inputDomain);
+ const domain = scope === 'root' ? rootDomain || inputDomain : inputDomain;
+ if (!domain) {
+ this.core.showToast('Unable to resolve root domain for this entry', 'error');
+ return;
+ }
+ await this.blockDomainInCustomDns(domain);
+ this.core.showToast(`Blocked domain via custom DNS: ${domain}`, 'success');
+ } else {
+ const scope = document.getElementById('netify-action-scope')?.value || 'all_sources';
+ await this.blockDestinationIp(flow, scope);
+ this.core.showToast('Firewall block rule added', 'success');
+ }
+
+ this.core.closeModal('netify-flow-action-modal');
+ } catch (err) {
+ console.error('Failed to save Netify flow action:', err);
+ this.core.showToast(`Failed to save action: ${err?.message || 'unknown error'}`, 'error');
+ } finally {
+ this.setFlowActionBusy(false);
+ }
+ }
+
+ setFlowActionBusy(busy) {
+ const btn = document.getElementById('save-netify-flow-action-btn');
+ if (!btn) return;
+ btn.disabled = Boolean(busy);
+ btn.style.opacity = busy ? '0.55' : '1';
+ btn.style.cursor = busy ? 'not-allowed' : '';
+ btn.textContent = busy ? 'SAVING...' : 'SAVE ACTION';
+ }
+
+ async blockDomainInCustomDns(domain) {
+ let targetSection = '';
+ try {
+ const [status, result] = await this.core.uciGet('dhcp');
+ if (status === 0 && result?.values) {
+ for (const [section, cfg] of Object.entries(result.values)) {
+ if (cfg?.['.type'] !== 'domain') continue;
+ if (String(cfg.name || '').trim().toLowerCase() === domain) {
+ targetSection = section;
+ break;
+ }
+ }
+ }
+ } catch {}
+
+ const values = {
+ name: domain,
+ ip: '127.0.0.1'
+ };
+ if (targetSection) {
+ await this.core.uciSet('dhcp', targetSection, values);
+ } else {
+ const [, res] = await this.core.uciAdd('dhcp', 'domain');
+ const section = res?.section;
+ if (!section) throw new Error('Failed to create custom DNS entry');
+ await this.core.uciSet('dhcp', section, values);
+ }
+ await this.core.uciCommit('dhcp');
+ try {
+ await this.exec('/etc/init.d/dnsmasq', ['restart']);
+ } catch {}
+ }
+
+ async blockDestinationIp(flow, scope) {
+ const destIp = String(flow.destIp || '').trim();
+ if (!this.isValidIp(destIp)) throw new Error('Destination IP missing/invalid');
+
+ const values = {
+ name: `moci_netify_block_${Date.now()}`,
+ src: 'lan',
+ dest: 'wan',
+ proto: 'all',
+ dest_ip: destIp,
+ target: 'REJECT',
+ enabled: '1'
+ };
+ if (scope === 'source_dest') {
+ const srcIp = String(flow.localIp || '').trim();
+ if (!this.isValidIp(srcIp)) throw new Error('Source IP missing/invalid for source→dest block');
+ values.src_ip = srcIp;
+ }
+ if (this.isIPv6(destIp)) values.family = 'ipv6';
+ if (this.isIPv4(destIp)) values.family = 'ipv4';
+
+ const [, res] = await this.core.uciAdd('firewall', 'rule');
+ const section = res?.section;
+ if (!section) throw new Error('Failed to create firewall rule');
+ await this.core.uciSet('firewall', section, values);
+ await this.core.uciCommit('firewall');
+ try {
+ await this.exec('/etc/init.d/firewall', ['restart']);
+ } catch (err) {
+ console.warn('Firewall restart failed after rule commit:', err);
+ }
+ }
+
+ sanitizeDomain(value) {
+ const v = String(value || '')
+ .trim()
+ .toLowerCase();
+ if (!v) return '';
+ if (!/^[a-z0-9.-]+$/.test(v)) return '';
+ if (v.length > 253 || v.startsWith('.') || v.endsWith('.') || v.includes('..')) return '';
+ if (this.isValidIp(v)) return '';
+ return v;
+ }
+
+ extractRootDomain(domain) {
+ const d = this.sanitizeDomain(domain);
+ if (!d) return '';
+ const parts = d.split('.').filter(Boolean);
+ if (parts.length < 2) return d;
+
+ const last2 = parts.slice(-2).join('.');
+ const last3 = parts.slice(-3).join('.');
+ const sldTlds = new Set([
+ 'co.uk',
+ 'org.uk',
+ 'ac.uk',
+ 'gov.uk',
+ 'co.jp',
+ 'com.au',
+ 'net.au',
+ 'org.au',
+ 'co.nz'
+ ]);
+ const tld2 = parts.slice(-2).join('.');
+ if (parts.length >= 3 && sldTlds.has(tld2)) return last3;
+ return last2;
+ }
+
+ isIPv4(value) {
+ const parts = String(value || '').trim().split('.');
+ if (parts.length !== 4) return false;
+ return parts.every(part => {
+ if (!/^\d+$/.test(part)) return false;
+ const n = Number(part);
+ return n >= 0 && n <= 255;
+ });
+ }
+
+ isIPv6(value) {
+ return /^[0-9a-f:]+$/i.test(String(value || '').trim()) && String(value || '').includes(':');
+ }
+
+ isValidIp(value) {
+ return this.isIPv4(value) || this.isIPv6(value);
+ }
+
+ formatTimestamp(ts) {
+ const d = new Date(ts);
+ return d.toLocaleString([], {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ });
+ }
+
+ async execShell(cmd) {
+ return this.exec('/bin/sh', ['-c', cmd], { timeout: 12000 });
+ }
+
+ async exec(command, params = [], options = {}) {
+ const [status, result] = await this.core.ubusCall('file', 'exec', { command, params }, options);
+ if (status !== 0) {
+ const err = new Error(`${command} failed with status ${status}`);
+ err.ubusStatus = status;
+ throw err;
+ }
+ if (result && Number(result.code) !== 0) {
+ const stderr = String(result.stderr || '').trim();
+ const stdout = String(result.stdout || '').trim();
+ const details = stderr || stdout || `exit ${result.code}`;
+ const err = new Error(`${command} failed: ${details}`);
+ err.exitCode = Number(result.code);
+ err.stderr = stderr;
+ err.stdout = stdout;
+ throw err;
+ }
+ return result || {};
+ }
+
+ describeExecFailure(err, fallback) {
+ const msg = String(err?.message || '').trim();
+ if (String(err?.ubusStatus) === '6' || /status 6/.test(msg)) {
+ return `${fallback}: permission denied (ACL/session). Re-login and restart rpcd if needed.`;
+ }
+ return msg ? `${fallback}: ${msg}` : fallback;
+ }
+
+ shellQuote(value) {
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
+ }
+}
diff --git a/moci/js/modules/network.js b/moci/js/modules/network.js
index 9aaeb82..71e7435 100644
--- a/moci/js/modules/network.js
+++ b/moci/js/modules/network.js
@@ -1,55 +1,11 @@
-const FORWARD_FIELDS = {
- 'edit-forward-name': 'name',
- 'edit-forward-proto': 'proto',
- 'edit-forward-src-dport': 'src_dport',
- 'edit-forward-dest-ip': 'dest_ip',
- 'edit-forward-dest-port': 'dest_port',
- 'edit-forward-enabled': 'enabled'
-};
-
-const FW_RULE_FIELDS = {
- 'edit-fw-rule-name': 'name',
- 'edit-fw-rule-target': 'target',
- 'edit-fw-rule-src': 'src',
- 'edit-fw-rule-dest': 'dest',
- 'edit-fw-rule-proto': 'proto',
- 'edit-fw-rule-dest-port': 'dest_port',
- 'edit-fw-rule-src-ip': 'src_ip'
-};
-
-const STATIC_LEASE_FIELDS = {
- 'edit-static-lease-name': 'name',
- 'edit-static-lease-mac': 'mac',
- 'edit-static-lease-ip': 'ip'
-};
-
-const DNS_ENTRY_FIELDS = {
- 'edit-dns-hostname': 'name',
- 'edit-dns-ip': 'ip'
-};
-
-const QOS_RULE_FIELDS = {
- 'edit-qos-rule-priority': 'target',
- 'edit-qos-rule-proto': 'proto',
- 'edit-qos-rule-ports': 'ports',
- 'edit-qos-rule-srchost': 'srchost'
-};
-
-const DDNS_FIELDS = {
- 'edit-ddns-service': 'service_name',
- 'edit-ddns-hostname': ['lookup_host', 'domain'],
- 'edit-ddns-username': 'username',
- 'edit-ddns-password': 'password',
- 'edit-ddns-check-interval': 'check_interval',
- 'edit-ddns-enabled': 'enabled'
-};
-
export default class NetworkModule {
constructor(core) {
this.core = core;
this.subTabs = null;
this.cleanups = [];
this.hostsRaw = '';
+ this.connectionsRefreshTimer = null;
+ this.isRefreshingConnections = false;
this.core.registerRoute('/network', (path, subPaths) => {
const pageElement = document.getElementById('network-page');
@@ -62,9 +18,11 @@ export default class NetworkModule {
firewall: () => this.loadFirewall(),
dhcp: () => this.loadDHCP(),
dns: () => this.loadDNS(),
+ adblock: () => this.loadAdblock(),
ddns: () => this.loadDDNS(),
qos: () => this.loadQoS(),
vpn: () => this.loadVPN(),
+ connections: () => this.loadConnections(),
diagnostics: () => this.loadDiagnostics()
});
this.subTabs.attachListeners();
@@ -78,46 +36,104 @@ export default class NetworkModule {
}
setupModals() {
- const modals = [
- { prefix: 'interface', save: () => this.saveInterface() },
- { prefix: 'wireless', save: () => this.saveWireless() },
- { prefix: 'forward', save: () => this.saveForward() },
- { prefix: 'fw-rule', save: () => this.saveFirewallRule() },
- { prefix: 'static-lease', save: () => this.saveStaticLease() },
- { prefix: 'dns-entry', save: () => this.saveDnsEntry() },
- { prefix: 'host-entry', save: () => this.saveHostEntry() },
- { prefix: 'ddns', save: () => this.saveDDNS() },
- { prefix: 'qos-rule', save: () => this.saveQoSRule() },
- { prefix: 'wg-peer', save: () => this.saveWgPeer() }
- ];
-
- modals.forEach(m => {
- this.core.setupModal({
- modalId: `${m.prefix}-modal`,
- closeBtnId: `close-${m.prefix}-modal`,
- cancelBtnId: `cancel-${m.prefix}-btn`,
- saveBtnId: `save-${m.prefix}-btn`,
- saveHandler: m.save
- });
+ this.core.setupModal({
+ modalId: 'interface-modal',
+ closeBtnId: 'close-interface-modal',
+ cancelBtnId: 'cancel-interface-btn',
+ saveBtnId: 'save-interface-btn',
+ saveHandler: () => this.saveInterface()
+ });
+
+ this.core.setupModal({
+ modalId: 'wireless-modal',
+ closeBtnId: 'close-wireless-modal',
+ cancelBtnId: 'cancel-wireless-btn',
+ saveBtnId: 'save-wireless-btn',
+ saveHandler: () => this.saveWireless()
+ });
+
+ this.core.setupModal({
+ modalId: 'forward-modal',
+ closeBtnId: 'close-forward-modal',
+ cancelBtnId: 'cancel-forward-btn',
+ saveBtnId: 'save-forward-btn',
+ saveHandler: () => this.saveForward()
+ });
+
+ this.core.setupModal({
+ modalId: 'fw-rule-modal',
+ closeBtnId: 'close-fw-rule-modal',
+ cancelBtnId: 'cancel-fw-rule-btn',
+ saveBtnId: 'save-fw-rule-btn',
+ saveHandler: () => this.saveFirewallRule()
+ });
+
+ this.core.setupModal({
+ modalId: 'static-lease-modal',
+ closeBtnId: 'close-static-lease-modal',
+ cancelBtnId: 'cancel-static-lease-btn',
+ saveBtnId: 'save-static-lease-btn',
+ saveHandler: () => this.saveStaticLease()
});
- const addButtons = [
- ['add-forward-btn', 'forward-modal'],
- ['add-fw-rule-btn', 'fw-rule-modal'],
- ['add-static-lease-btn', 'static-lease-modal'],
- ['add-dns-entry-btn', 'dns-entry-modal'],
- ['add-host-entry-btn', 'host-entry-modal'],
- ['add-ddns-btn', 'ddns-modal'],
- ['add-qos-rule-btn', 'qos-rule-modal'],
- ['add-wg-peer-btn', 'wg-peer-modal']
- ];
-
- addButtons.forEach(([btnId, modalId]) => {
- document.getElementById(btnId)?.addEventListener('click', () => {
+ this.core.setupModal({
+ modalId: 'dns-entry-modal',
+ closeBtnId: 'close-dns-entry-modal',
+ cancelBtnId: 'cancel-dns-entry-btn',
+ saveBtnId: 'save-dns-entry-btn',
+ saveHandler: () => this.saveDnsEntry()
+ });
+
+ this.core.setupModal({
+ modalId: 'host-entry-modal',
+ closeBtnId: 'close-host-entry-modal',
+ cancelBtnId: 'cancel-host-entry-btn',
+ saveBtnId: 'save-host-entry-btn',
+ saveHandler: () => this.saveHostEntry()
+ });
+
+ this.core.setupModal({
+ modalId: 'ddns-modal',
+ closeBtnId: 'close-ddns-modal',
+ cancelBtnId: 'cancel-ddns-btn',
+ saveBtnId: 'save-ddns-btn',
+ saveHandler: () => this.saveDDNS()
+ });
+
+ this.core.setupModal({
+ modalId: 'qos-rule-modal',
+ closeBtnId: 'close-qos-rule-modal',
+ cancelBtnId: 'cancel-qos-rule-btn',
+ saveBtnId: 'save-qos-rule-btn',
+ saveHandler: () => this.saveQoSRule()
+ });
+
+ this.core.setupModal({
+ modalId: 'wg-peer-modal',
+ closeBtnId: 'close-wg-peer-modal',
+ cancelBtnId: 'cancel-wg-peer-btn',
+ saveBtnId: 'save-wg-peer-btn',
+ saveHandler: () => this.saveWgPeer()
+ });
+
+ const addBtn = (id, modalId) => {
+ document.getElementById(id)?.addEventListener('click', () => {
this.core.resetModal(modalId);
+ if (id === 'add-forward-btn') {
+ this.loadForwardDeviceOptions();
+ }
this.core.openModal(modalId);
});
- });
+ };
+
+ addBtn('add-forward-btn', 'forward-modal');
+ addBtn('add-fw-rule-btn', 'fw-rule-modal');
+ addBtn('add-static-lease-btn', 'static-lease-modal');
+ addBtn('add-dns-entry-btn', 'dns-entry-modal');
+ addBtn('add-host-entry-btn', 'host-entry-modal');
+ addBtn('add-ddns-btn', 'ddns-modal');
+ addBtn('add-qos-rule-btn', 'qos-rule-modal');
+ addBtn('add-wg-peer-btn', 'wg-peer-modal');
const tables = {
'interfaces-table': { edit: id => this.editInterface(id), delete: id => this.deleteInterface(id) },
@@ -140,15 +156,26 @@ export default class NetworkModule {
document.getElementById('save-qos-config-btn')?.addEventListener('click', () => this.saveQoSConfig());
document.getElementById('save-wg-config-btn')?.addEventListener('click', () => this.saveWgConfig());
document.getElementById('generate-wg-keys-btn')?.addEventListener('click', () => this.generateWgKeys());
+ document.getElementById('save-adblock-settings-btn')?.addEventListener('click', () => this.saveAdblockSettings());
+ document.getElementById('refresh-adblock-btn')?.addEventListener('click', () => this.loadAdblock());
+ document.getElementById('add-adblock-list-btn')?.addEventListener('click', () => this.addAdblockTargetList());
+
+ const adblockCleanup = this.core.delegateActions('adblock-targets-table', {
+ toggle: id => this.toggleAdblockTargetList(id),
+ delete: id => this.deleteAdblockTargetList(id)
+ });
+ if (adblockCleanup) this.cleanups.push(adblockCleanup);
}
setupDiagnostics() {
document.getElementById('ping-btn')?.addEventListener('click', () => this.runDiagnostic('ping'));
document.getElementById('traceroute-btn')?.addEventListener('click', () => this.runDiagnostic('traceroute'));
+ document.getElementById('nslookup-btn')?.addEventListener('click', () => this.runDiagnostic('nslookup'));
document.getElementById('wol-btn')?.addEventListener('click', () => this.runWoL());
}
cleanup() {
+ this.stopConnectionsAutoRefresh();
if (this.subTabs) {
this.subTabs.cleanup();
this.subTabs = null;
@@ -161,13 +188,24 @@ export default class NetworkModule {
async loadInterfaces() {
await this.core.loadResource('interfaces-table', 6, 'network', async () => {
- const [, result] = await this.core.ubusCall('network.interface', 'dump', {});
+ const [[, result], procNetDevMap] = await Promise.all([
+ this.core.ubusCall('network.interface', 'dump', {}),
+ this.readProcNetDevMap()
+ ]);
if (!result?.interface) throw new Error('No data');
- this.core.renderTable('#interfaces-table', result.interface, 6, 'No interfaces found', iface => {
- const ipv4 = iface['ipv4-address']?.[0]?.address || '---.---.---.---';
- const rx = this.core.formatBytes(iface.statistics?.rx_bytes || 0);
- const tx = this.core.formatBytes(iface.statistics?.tx_bytes || 0);
- return `
+ const tbody = document.querySelector('#interfaces-table tbody');
+ if (!tbody) return;
+ if (result.interface.length === 0) {
+ this.core.renderEmptyTable(tbody, 6, 'No interfaces found');
+ return;
+ }
+ tbody.innerHTML = result.interface
+ .map(iface => {
+ const ipv4 = iface['ipv4-address']?.[0]?.address || '---.---.---.---';
+ const { rxBytes, txBytes } = this.resolveInterfaceTotals(iface, procNetDevMap);
+ const rx = this.core.formatBytes(rxBytes);
+ const tx = this.core.formatBytes(txBytes);
+ return `
${this.core.escapeHtml(iface.interface)}
${this.core.escapeHtml(iface.proto || 'none').toUpperCase()}
${iface.up ? this.core.renderBadge('success', 'UP') : this.core.renderBadge('error', 'DOWN')}
@@ -175,10 +213,65 @@ export default class NetworkModule {
${rx} / ${tx}
${this.core.renderActionButtons(iface.interface)}
`;
- });
+ })
+ .join('');
});
}
+ async readProcNetDevMap() {
+ const map = new Map();
+ try {
+ const [status, result] = await this.core.ubusCall('file', 'read', { path: '/proc/net/dev' });
+ if (status !== 0 || !result?.data) return map;
+ const lines = String(result.data)
+ .split('\n')
+ .slice(2)
+ .map(line => line.trim())
+ .filter(Boolean);
+ for (const line of lines) {
+ const [devPart, rest] = line.split(':');
+ if (!devPart || !rest) continue;
+ const dev = devPart.trim();
+ const fields = rest
+ .trim()
+ .split(/\s+/)
+ .map(v => Number(v) || 0);
+ // /proc/net/dev format: rx bytes is field 0, tx bytes is field 8
+ map.set(dev, { rxBytes: fields[0] || 0, txBytes: fields[8] || 0 });
+ }
+ } catch {}
+ return map;
+ }
+
+ resolveInterfaceTotals(iface, procNetDevMap) {
+ const stats = iface?.statistics || iface?.stats || {};
+ const directRx = Number(stats.rx_bytes ?? stats.rxBytes ?? 0) || 0;
+ const directTx = Number(stats.tx_bytes ?? stats.txBytes ?? 0) || 0;
+ if (directRx > 0 || directTx > 0) {
+ return { rxBytes: directRx, txBytes: directTx };
+ }
+
+ const candidates = [];
+ const addCandidate = value => {
+ const v = String(value || '').trim();
+ if (v && !candidates.includes(v)) candidates.push(v);
+ };
+
+ addCandidate(iface?.l3_device);
+ if (!Array.isArray(iface?.device)) addCandidate(iface?.device);
+ addCandidate(iface?.interface);
+ if (Array.isArray(iface?.device)) {
+ for (const d of iface.device) addCandidate(d);
+ }
+
+ for (const dev of candidates) {
+ const fromProc = procNetDevMap.get(dev);
+ if (fromProc) return fromProc;
+ }
+
+ return { rxBytes: 0, txBytes: 0 };
+ }
+
async editInterface(id) {
try {
const [status, result] = await this.core.uciGet('network', id);
@@ -222,7 +315,15 @@ export default class NetworkModule {
}
async deleteInterface(id) {
- await this.core.uciDeleteEntry('network', id, `Delete interface "${id}"?`, () => this.loadInterfaces());
+ if (!confirm(`Delete interface "${id}"?`)) return;
+ try {
+ await this.core.uciDelete('network', id);
+ await this.core.uciCommit('network');
+ this.core.showToast('Interface deleted', 'success');
+ this.loadInterfaces();
+ } catch {
+ this.core.showToast('Failed to delete interface', 'error');
+ }
}
async loadWireless() {
@@ -238,10 +339,17 @@ export default class NetworkModule {
if (val['.type'] === 'wifi-iface') ifaces.push({ section: key, ...val });
}
- this.core.renderTable('#wireless-table', ifaces, 6, 'No wireless interfaces found', iface => {
- const radio = radios[iface.device] || {};
- const disabled = iface.disabled === '1';
- return `
+ const tbody = document.querySelector('#wireless-table tbody');
+ if (!tbody) return;
+ if (ifaces.length === 0) {
+ this.core.renderEmptyTable(tbody, 6, 'No wireless interfaces found');
+ return;
+ }
+ tbody.innerHTML = ifaces
+ .map(iface => {
+ const radio = radios[iface.device] || {};
+ const disabled = iface.disabled === '1';
+ return `
${this.core.escapeHtml(iface.device || 'N/A')}
${this.core.escapeHtml(iface.ssid || 'N/A')}
${this.core.escapeHtml(radio.channel || 'auto')}
@@ -249,7 +357,8 @@ export default class NetworkModule {
${this.core.escapeHtml(iface.encryption || 'none').toUpperCase()}
${this.core.renderActionButtons(iface.section)}
`;
- });
+ })
+ .join('');
});
}
@@ -266,8 +375,9 @@ export default class NetworkModule {
document.getElementById('edit-wifi-disabled').value = c.disabled || '0';
document.getElementById('edit-wifi-hidden').value = c.hidden || '0';
- if (c.device) {
- const [rs, rr] = await this.core.uciGet('wireless', c.device);
+ const radioSection = c.device;
+ if (radioSection) {
+ const [rs, rr] = await this.core.uciGet('wireless', radioSection);
if (rs === 0 && rr?.values) {
document.getElementById('edit-wifi-channel').value = rr.values.channel || 'auto';
document.getElementById('edit-wifi-txpower').value = rr.values.txpower || '';
@@ -313,90 +423,225 @@ export default class NetworkModule {
}
async deleteWireless(id) {
- await this.core.uciDeleteEntry('wireless', id, 'Delete this wireless interface?', () => this.loadWireless());
+ if (!confirm('Delete this wireless interface?')) return;
+ try {
+ await this.core.uciDelete('wireless', id);
+ await this.core.uciCommit('wireless');
+ this.core.showToast('Wireless interface deleted', 'success');
+ this.loadWireless();
+ } catch {
+ this.core.showToast('Failed to delete wireless interface', 'error');
+ }
}
async loadFirewall() {
await this.core.loadResource('firewall-table', 7, 'firewall', async () => {
const [status, result] = await this.core.uciGet('firewall');
if (status !== 0 || !result?.values) throw new Error('No data');
+ const config = result.values;
- const forwards = this.core.filterUciSections(result.values, 'redirect');
- const rules = this.core.filterUciSections(result.values, 'rule');
-
- this.core.renderTable(
- '#firewall-table',
- forwards,
- 7,
- 'No port forwarding rules',
- f => `
- ${this.core.escapeHtml(f.name || f.section)}
- ${this.core.escapeHtml(f.proto || 'tcp')}
- ${this.core.escapeHtml(f.src_dport || 'N/A')}
- ${this.core.escapeHtml(f.dest_ip || 'N/A')}
- ${this.core.escapeHtml(f.dest_port || f.src_dport || 'N/A')}
- ${this.core.renderStatusBadge(f.enabled !== '0')}
- ${this.core.renderActionButtons(f.section)}
- `
- );
-
- this.core.renderTable(
- '#fw-rules-table',
- rules,
- 7,
- 'No firewall rules',
- r => `
- ${this.core.escapeHtml(r.name || r.section)}
- ${this.core.escapeHtml(r.src || 'Any')}
- ${this.core.escapeHtml(r.dest || 'Any')}
- ${this.core.escapeHtml(r.proto || 'Any')}
- ${this.core.escapeHtml(r.dest_port || 'Any')}
- ${this.core.renderBadge(r.target === 'ACCEPT' ? 'success' : 'error', r.target || 'DROP')}
- ${this.core.renderActionButtons(r.section)}
- `
- );
+ const forwards = Object.entries(config)
+ .filter(([, v]) => v['.type'] === 'redirect')
+ .map(([k, v]) => ({ section: k, ...v }));
+
+ const rules = Object.entries(config)
+ .filter(([, v]) => v['.type'] === 'rule')
+ .map(([k, v]) => ({ section: k, ...v }));
+
+ const fwTbody = document.querySelector('#firewall-table tbody');
+ if (fwTbody) {
+ if (forwards.length === 0) {
+ this.core.renderEmptyTable(fwTbody, 7, 'No port forwarding rules');
+ } else {
+ fwTbody.innerHTML = forwards
+ .map(
+ f => `
+ ${this.core.escapeHtml(f.name || f.section)}
+ ${this.core.escapeHtml(f.proto || 'tcp')}
+ ${this.core.escapeHtml(f.src_dport || 'N/A')}
+ ${this.core.escapeHtml(f.dest_ip || 'N/A')}
+ ${this.core.escapeHtml(f.dest_port || f.src_dport || 'N/A')}
+ ${this.core.renderStatusBadge(f.enabled !== '0')}
+ ${this.core.renderActionButtons(f.section)}
+ `
+ )
+ .join('');
+ }
+ }
+
+ const rulesTbody = document.querySelector('#fw-rules-table tbody');
+ if (rulesTbody) {
+ if (rules.length === 0) {
+ this.core.renderEmptyTable(rulesTbody, 8, 'No firewall rules');
+ } else {
+ rulesTbody.innerHTML = rules
+ .map(
+ r => `
+ ${this.core.escapeHtml(r.name || r.section)}
+ ${this.core.escapeHtml(r.src || 'Any')}
+ ${this.core.escapeHtml(r.src_ip || 'Any')}
+ ${this.core.escapeHtml(r.dest || 'Any')}
+ ${this.core.escapeHtml(r.proto || 'Any')}
+ ${this.core.escapeHtml(r.dest_port || 'Any')}
+ ${this.core.renderBadge(r.target === 'ACCEPT' ? 'success' : 'error', r.target || 'DROP')}
+ ${this.core.renderActionButtons(r.section)}
+ `
+ )
+ .join('');
+ }
+ }
});
}
- editForward(id) {
- this.core.uciEdit('firewall', id, FORWARD_FIELDS, 'forward-modal', 'edit-forward-section');
+ async editForward(id) {
+ try {
+ const [status, result] = await this.core.uciGet('firewall', id);
+ if (status !== 0 || !result?.values) throw new Error('Not found');
+ const c = result.values;
+ await this.loadForwardDeviceOptions();
+ document.getElementById('edit-forward-section').value = id;
+ document.getElementById('edit-forward-name').value = c.name || '';
+ document.getElementById('edit-forward-proto').value = c.proto || 'tcp';
+ document.getElementById('edit-forward-src-dport').value = c.src_dport || '';
+ document.getElementById('edit-forward-dest-ip').value = c.dest_ip || '';
+ document.getElementById('edit-forward-dest-port').value = c.dest_port || '';
+ document.getElementById('edit-forward-enabled').value = c.enabled !== '0' ? '1' : '0';
+ this.core.openModal('forward-modal');
+ } catch {
+ this.core.showToast('Failed to load rule', 'error');
+ }
+ }
+
+ async loadForwardDeviceOptions() {
+ const list = document.getElementById('forward-device-list');
+ if (!list) return;
+
+ let leases = [];
+ try {
+ const [status, result] = await this.core.ubusCall('luci-rpc', 'getDHCPLeases', {});
+ if (status === 0 && Array.isArray(result?.dhcp_leases)) {
+ leases = result.dhcp_leases;
+ }
+ } catch {}
+
+ const options = leases
+ .map(lease => ({
+ ip: String(lease.ipaddr || '').trim(),
+ hostname: String(lease.hostname || 'Unknown').trim()
+ }))
+ .filter(item => item.ip)
+ .sort((a, b) => a.hostname.localeCompare(b.hostname));
+
+ list.innerHTML = '';
+ for (const option of options) {
+ const el = document.createElement('option');
+ el.value = option.ip;
+ el.label = `${option.hostname} (${option.ip})`;
+ list.appendChild(el);
+ }
}
- saveForward() {
- this.core.uciSave({
- config: 'firewall',
- uciType: 'redirect',
- modalId: 'forward-modal',
- sectionIdField: 'edit-forward-section',
- fieldMap: FORWARD_FIELDS,
- defaults: { src: 'wan', dest: 'lan', target: 'DNAT' },
- reloadFn: () => this.loadFirewall(),
- successMsg: 'Port forward saved'
- });
+ async saveForward() {
+ const section = document.getElementById('edit-forward-section').value;
+ const values = {
+ name: document.getElementById('edit-forward-name').value,
+ proto: document.getElementById('edit-forward-proto').value,
+ src_dport: document.getElementById('edit-forward-src-dport').value,
+ dest_ip: document.getElementById('edit-forward-dest-ip').value,
+ dest_port: document.getElementById('edit-forward-dest-port').value,
+ enabled: document.getElementById('edit-forward-enabled').value,
+ src: 'wan',
+ dest: 'lan',
+ target: 'DNAT'
+ };
+ try {
+ if (section) {
+ await this.core.uciSet('firewall', section, values);
+ } else {
+ const [, res] = await this.core.uciAdd('firewall', 'redirect');
+ if (!res?.section) throw new Error('Failed to create section');
+ await this.core.uciSet('firewall', res.section, values);
+ }
+ await this.core.uciCommit('firewall');
+ this.core.closeModal('forward-modal');
+ this.core.showToast('Port forward saved', 'success');
+ this.loadFirewall();
+ } catch {
+ this.core.showToast('Failed to save port forward', 'error');
+ }
}
- deleteForward(id) {
- this.core.uciDeleteEntry('firewall', id, 'Delete this port forwarding rule?', () => this.loadFirewall());
+ async deleteForward(id) {
+ if (!confirm('Delete this port forwarding rule?')) return;
+ try {
+ await this.core.uciDelete('firewall', id);
+ await this.core.uciCommit('firewall');
+ this.core.showToast('Rule deleted', 'success');
+ this.loadFirewall();
+ } catch {
+ this.core.showToast('Failed to delete rule', 'error');
+ }
}
- editFirewallRule(id) {
- this.core.uciEdit('firewall', id, FW_RULE_FIELDS, 'fw-rule-modal', 'edit-fw-rule-section');
+ async editFirewallRule(id) {
+ try {
+ const [status, result] = await this.core.uciGet('firewall', id);
+ if (status !== 0 || !result?.values) throw new Error('Not found');
+ const c = result.values;
+ document.getElementById('edit-fw-rule-section').value = id;
+ document.getElementById('edit-fw-rule-name').value = c.name || '';
+ document.getElementById('edit-fw-rule-target').value = c.target || 'ACCEPT';
+ document.getElementById('edit-fw-rule-src').value = c.src || '';
+ document.getElementById('edit-fw-rule-dest').value = c.dest || '';
+ document.getElementById('edit-fw-rule-proto').value = c.proto || '';
+ document.getElementById('edit-fw-rule-dest-port').value = c.dest_port || '';
+ document.getElementById('edit-fw-rule-src-ip').value = c.src_ip || '';
+ document.getElementById('edit-fw-rule-dest-ip').value = c.dest_ip || '';
+ this.core.openModal('fw-rule-modal');
+ } catch {
+ this.core.showToast('Failed to load rule', 'error');
+ }
}
- saveFirewallRule() {
- this.core.uciSave({
- config: 'firewall',
- uciType: 'rule',
- modalId: 'fw-rule-modal',
- sectionIdField: 'edit-fw-rule-section',
- fieldMap: FW_RULE_FIELDS,
- reloadFn: () => this.loadFirewall(),
- successMsg: 'Firewall rule saved'
- });
+ async saveFirewallRule() {
+ const section = document.getElementById('edit-fw-rule-section').value;
+ const values = {
+ name: document.getElementById('edit-fw-rule-name').value,
+ target: document.getElementById('edit-fw-rule-target').value,
+ src: document.getElementById('edit-fw-rule-src').value,
+ dest: document.getElementById('edit-fw-rule-dest').value,
+ proto: document.getElementById('edit-fw-rule-proto').value,
+ dest_port: document.getElementById('edit-fw-rule-dest-port').value,
+ src_ip: document.getElementById('edit-fw-rule-src-ip').value,
+ dest_ip: document.getElementById('edit-fw-rule-dest-ip').value
+ };
+ try {
+ if (section) {
+ await this.core.uciSet('firewall', section, values);
+ } else {
+ const [, res] = await this.core.uciAdd('firewall', 'rule');
+ if (!res?.section) throw new Error('Failed to create section');
+ await this.core.uciSet('firewall', res.section, values);
+ }
+ await this.core.uciCommit('firewall');
+ this.core.closeModal('fw-rule-modal');
+ this.core.showToast('Firewall rule saved', 'success');
+ this.loadFirewall();
+ } catch {
+ this.core.showToast('Failed to save firewall rule', 'error');
+ }
}
- deleteFirewallRule(id) {
- this.core.uciDeleteEntry('firewall', id, 'Delete this firewall rule?', () => this.loadFirewall());
+ async deleteFirewallRule(id) {
+ if (!confirm('Delete this firewall rule?')) return;
+ try {
+ await this.core.uciDelete('firewall', id);
+ await this.core.uciCommit('firewall');
+ this.core.showToast('Rule deleted', 'success');
+ this.loadFirewall();
+ } catch {
+ this.core.showToast('Failed to delete rule', 'error');
+ }
}
async loadDHCP() {
@@ -407,74 +652,130 @@ export default class NetworkModule {
if (s === 0 && r?.dhcp_leases) leases = r.dhcp_leases;
} catch {}
- this.core.renderTable(
- '#dhcp-leases-table',
- leases,
- 4,
- 'No active DHCP leases',
- l => `
- ${this.core.escapeHtml(l.hostname || 'Unknown')}
- ${this.core.escapeHtml(l.ipaddr || 'N/A')}
- ${this.core.escapeHtml(l.macaddr || 'N/A')}
- ${l.expires > 0 ? l.expires + 's' : 'Permanent'}
- `
- );
+ const leasesTbody = document.querySelector('#dhcp-leases-table tbody');
+ if (leasesTbody) {
+ if (leases.length === 0) {
+ this.core.renderEmptyTable(leasesTbody, 4, 'No active DHCP leases');
+ } else {
+ leasesTbody.innerHTML = leases
+ .map(
+ l => `
+ ${this.core.escapeHtml(l.hostname || 'Unknown')}
+ ${this.core.escapeHtml(l.ipaddr || 'N/A')}
+ ${this.core.escapeHtml(l.macaddr || 'N/A')}
+ ${l.expires > 0 ? l.expires + 's' : 'Permanent'}
+ `
+ )
+ .join('');
+ }
+ }
const [status, result] = await this.core.uciGet('dhcp');
if (status !== 0 || !result?.values) return;
- const statics = this.core.filterUciSections(result.values, 'host');
- this.core.renderTable(
- '#dhcp-static-table',
- statics,
- 4,
- 'No static leases',
- s => `
- ${this.core.escapeHtml(s.name || 'N/A')}
- ${this.core.escapeHtml(s.mac || 'N/A')}
- ${this.core.escapeHtml(s.ip || 'N/A')}
- ${this.core.renderActionButtons(s.section)}
- `
- );
+ const statics = Object.entries(result.values)
+ .filter(([, v]) => v['.type'] === 'host')
+ .map(([k, v]) => ({ section: k, ...v }));
+
+ const staticTbody = document.querySelector('#dhcp-static-table tbody');
+ if (staticTbody) {
+ if (statics.length === 0) {
+ this.core.renderEmptyTable(staticTbody, 4, 'No static leases');
+ } else {
+ staticTbody.innerHTML = statics
+ .map(s => {
+ const hasStaticIp = Boolean(String(s.ip || '').trim());
+ const ipCell = hasStaticIp
+ ? this.core.escapeHtml(s.ip)
+ : `N/A`;
+ return `
+ ${this.core.escapeHtml(s.name || 'N/A')}
+ ${this.core.escapeHtml(s.mac || 'N/A')}
+ ${ipCell}
+ ${this.core.renderActionButtons(s.section)}
+ `;
+ })
+ .join('');
+ }
+ }
});
}
- editStaticLease(id) {
- this.core.uciEdit('dhcp', id, STATIC_LEASE_FIELDS, 'static-lease-modal', 'edit-static-lease-section');
+ async editStaticLease(id) {
+ try {
+ const [status, result] = await this.core.uciGet('dhcp', id);
+ if (status !== 0 || !result?.values) throw new Error('Not found');
+ const c = result.values;
+ document.getElementById('edit-static-lease-section').value = id;
+ document.getElementById('edit-static-lease-name').value = c.name || '';
+ document.getElementById('edit-static-lease-mac').value = c.mac || '';
+ document.getElementById('edit-static-lease-ip').value = c.ip || '';
+ this.core.openModal('static-lease-modal');
+ } catch {
+ this.core.showToast('Failed to load static lease', 'error');
+ }
}
- saveStaticLease() {
- this.core.uciSave({
- config: 'dhcp',
- uciType: 'host',
- modalId: 'static-lease-modal',
- sectionIdField: 'edit-static-lease-section',
- fieldMap: STATIC_LEASE_FIELDS,
- reloadFn: () => this.loadDHCP(),
- successMsg: 'Static lease saved'
- });
+ async saveStaticLease() {
+ const section = document.getElementById('edit-static-lease-section').value;
+ const values = {
+ name: document.getElementById('edit-static-lease-name').value,
+ mac: document.getElementById('edit-static-lease-mac').value,
+ ip: document.getElementById('edit-static-lease-ip').value
+ };
+ try {
+ if (section) {
+ await this.core.uciSet('dhcp', section, values);
+ } else {
+ const [, res] = await this.core.uciAdd('dhcp', 'host');
+ if (!res?.section) throw new Error('Failed to create section');
+ await this.core.uciSet('dhcp', res.section, values);
+ }
+ await this.core.uciCommit('dhcp');
+ this.core.closeModal('static-lease-modal');
+ this.core.showToast('Static lease saved', 'success');
+ this.loadDHCP();
+ } catch {
+ this.core.showToast('Failed to save static lease', 'error');
+ }
}
- deleteStaticLease(id) {
- this.core.uciDeleteEntry('dhcp', id, 'Delete this static lease?', () => this.loadDHCP());
+ async deleteStaticLease(id) {
+ if (!confirm('Delete this static lease?')) return;
+ try {
+ await this.core.uciDelete('dhcp', id);
+ await this.core.uciCommit('dhcp');
+ this.core.showToast('Static lease deleted', 'success');
+ this.loadDHCP();
+ } catch {
+ this.core.showToast('Failed to delete static lease', 'error');
+ }
}
async loadDNS() {
await this.core.loadResource('dns-entries-table', 3, 'dns', async () => {
const [status, result] = await this.core.uciGet('dhcp');
if (status === 0 && result?.values) {
- const domains = this.core.filterUciSections(result.values, 'domain');
- this.core.renderTable(
- '#dns-entries-table',
- domains,
- 3,
- 'No custom DNS entries',
- d => `
- ${this.core.escapeHtml(d.name || 'N/A')}
- ${this.core.escapeHtml(d.ip || 'N/A')}
- ${this.core.renderActionButtons(d.section)}
- `
- );
+ const domains = Object.entries(result.values)
+ .filter(([, v]) => v['.type'] === 'domain')
+ .map(([k, v]) => ({ section: k, ...v }));
+
+ const dnsTbody = document.querySelector('#dns-entries-table tbody');
+ if (dnsTbody) {
+ if (domains.length === 0) {
+ this.core.renderEmptyTable(dnsTbody, 3, 'No custom DNS entries');
+ } else {
+ dnsTbody.innerHTML = domains
+ .map(
+ d => `
+ ${this.core.escapeHtml(d.name || 'N/A')}
+ ${this.core.escapeHtml(d.ip || 'N/A')}
+ ${this.core.renderActionButtons(d.section)}
+ `
+ )
+ .join('');
+ }
+ }
}
try {
@@ -482,17 +783,22 @@ export default class NetworkModule {
if (hs === 0 && hr?.data) {
this.hostsRaw = hr.data;
const entries = this.parseHosts(hr.data);
- this.core.renderTable(
- '#hosts-table',
- entries,
- 3,
- 'No hosts entries',
- (e, i) => `
- ${this.core.escapeHtml(e.ip)}
- ${this.core.escapeHtml(e.names)}
- ${this.core.renderActionButtons(String(i))}
- `
- );
+ const hostsTbody = document.querySelector('#hosts-table tbody');
+ if (hostsTbody) {
+ if (entries.length === 0) {
+ this.core.renderEmptyTable(hostsTbody, 3, 'No hosts entries');
+ } else {
+ hostsTbody.innerHTML = entries
+ .map(
+ (e, i) => `
+ ${this.core.escapeHtml(e.ip)}
+ ${this.core.escapeHtml(e.names)}
+ ${this.core.renderActionButtons(String(i))}
+ `
+ )
+ .join('');
+ }
+ }
}
} catch {}
});
@@ -509,24 +815,53 @@ export default class NetworkModule {
.filter(e => e.ip && e.names);
}
- editDnsEntry(id) {
- this.core.uciEdit('dhcp', id, DNS_ENTRY_FIELDS, 'dns-entry-modal', 'edit-dns-entry-section');
+ async editDnsEntry(id) {
+ try {
+ const [status, result] = await this.core.uciGet('dhcp', id);
+ if (status !== 0 || !result?.values) throw new Error('Not found');
+ const c = result.values;
+ document.getElementById('edit-dns-entry-section').value = id;
+ document.getElementById('edit-dns-hostname').value = c.name || '';
+ document.getElementById('edit-dns-ip').value = c.ip || '';
+ this.core.openModal('dns-entry-modal');
+ } catch {
+ this.core.showToast('Failed to load DNS entry', 'error');
+ }
}
- saveDnsEntry() {
- this.core.uciSave({
- config: 'dhcp',
- uciType: 'domain',
- modalId: 'dns-entry-modal',
- sectionIdField: 'edit-dns-entry-section',
- fieldMap: DNS_ENTRY_FIELDS,
- reloadFn: () => this.loadDNS(),
- successMsg: 'DNS entry saved'
- });
+ async saveDnsEntry() {
+ const section = document.getElementById('edit-dns-entry-section').value;
+ const values = {
+ name: document.getElementById('edit-dns-hostname').value,
+ ip: document.getElementById('edit-dns-ip').value
+ };
+ try {
+ if (section) {
+ await this.core.uciSet('dhcp', section, values);
+ } else {
+ const [, res] = await this.core.uciAdd('dhcp', 'domain');
+ if (!res?.section) throw new Error('Failed to create section');
+ await this.core.uciSet('dhcp', res.section, values);
+ }
+ await this.core.uciCommit('dhcp');
+ this.core.closeModal('dns-entry-modal');
+ this.core.showToast('DNS entry saved', 'success');
+ this.loadDNS();
+ } catch {
+ this.core.showToast('Failed to save DNS entry', 'error');
+ }
}
- deleteDnsEntry(id) {
- this.core.uciDeleteEntry('dhcp', id, 'Delete this DNS entry?', () => this.loadDNS());
+ async deleteDnsEntry(id) {
+ if (!confirm('Delete this DNS entry?')) return;
+ try {
+ await this.core.uciDelete('dhcp', id);
+ await this.core.uciCommit('dhcp');
+ this.core.showToast('DNS entry deleted', 'success');
+ this.loadDNS();
+ } catch {
+ this.core.showToast('Failed to delete DNS entry', 'error');
+ }
}
editHostEntry(index) {
@@ -548,21 +883,18 @@ export default class NetworkModule {
return;
}
- const entries = this.parseHosts(this.hostsRaw);
- const parsedIndex = index === '' ? null : parseInt(index, 10);
- if (
- parsedIndex !== null &&
- (!Number.isInteger(parsedIndex) || parsedIndex < 0 || parsedIndex >= entries.length)
- ) {
- this.core.showToast('Hosts entry is out of date. Reload and try again.', 'error');
- return;
+ const lines = this.hostsRaw.split('\n');
+ const dataIndices = lines.map((l, i) => (l.trim() && !l.trim().startsWith('#') ? i : -1)).filter(i => i >= 0);
+
+ if (index !== '') {
+ const origIdx = dataIndices[parseInt(index)];
+ if (origIdx !== undefined) lines[origIdx] = `${ip}\t${names}`;
+ } else {
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
+ lines.push(`${ip}\t${names}`);
}
- const newContent = this.core.spliceFileLines(
- this.hostsRaw,
- l => l.trim() && !l.trim().startsWith('#'),
- index,
- `${ip}\t${names}`
- );
+
+ const newContent = lines.join('\n') + (this.hostsRaw.endsWith('\n') ? '' : '\n');
try {
await this.core.ubusCall('file', 'write', { path: '/etc/hosts', data: newContent });
this.core.closeModal('host-entry-modal');
@@ -575,18 +907,11 @@ export default class NetworkModule {
async deleteHostEntry(index) {
if (!confirm('Delete this hosts entry?')) return;
- const entries = this.parseHosts(this.hostsRaw);
- const parsedIndex = parseInt(index, 10);
- if (!Number.isInteger(parsedIndex) || parsedIndex < 0 || parsedIndex >= entries.length) {
- this.core.showToast('Hosts entry is out of date. Reload and try again.', 'error');
- return;
- }
- const newContent = this.core.spliceFileLines(
- this.hostsRaw,
- l => l.trim() && !l.trim().startsWith('#'),
- index,
- null
- );
+ const lines = this.hostsRaw.split('\n');
+ const dataIndices = lines.map((l, i) => (l.trim() && !l.trim().startsWith('#') ? i : -1)).filter(i => i >= 0);
+ const origIdx = dataIndices[parseInt(index)];
+ if (origIdx !== undefined) lines.splice(origIdx, 1);
+ const newContent = lines.join('\n') + (this.hostsRaw.endsWith('\n') ? '' : '\n');
try {
await this.core.ubusCall('file', 'write', { path: '/etc/hosts', data: newContent });
this.core.showToast('Hosts entry deleted', 'success');
@@ -596,18 +921,247 @@ export default class NetworkModule {
}
}
+ async loadAdblock() {
+ await this.core.loadResource('adblock-targets-table', 4, 'adblock', async () => {
+ const tbody = document.querySelector('#adblock-targets-table tbody');
+ if (!tbody) return;
+
+ const config = await this.readAdblockFastConfig();
+ if (!config || !config.values) {
+ document.getElementById('adblock-enabled').value = '0';
+ document.getElementById('adblock-config-update').value = '0';
+ this.core.renderEmptyTable(
+ tbody,
+ 4,
+ 'Adblock-Fast config not found. Install adblock-fast/luci-app-adblock-fast first.'
+ );
+ return;
+ }
+
+ let mainSection = null;
+ const rows = [];
+ for (const [section, cfg] of Object.entries(config.values)) {
+ const type = String(cfg?.['.type'] || '');
+ if ((type === 'adblock-fast' || section === 'config') && !mainSection) {
+ mainSection = { id: section, values: cfg };
+ } else if (type === 'file_url') {
+ rows.push({
+ id: section,
+ name: String(cfg.name || section),
+ url: String(cfg.url || ''),
+ enabled: this.isEnabledValue(cfg.enabled)
+ });
+ }
+ }
+
+ document.getElementById('adblock-enabled').value = this.isEnabledValue(
+ mainSection?.values?.enabled ?? '0'
+ )
+ ? '1'
+ : '0';
+ document.getElementById('adblock-config-update').value = this.isEnabledValue(
+ mainSection?.values?.config_update_enabled ?? '0'
+ )
+ ? '1'
+ : '0';
+
+ if (rows.length === 0) {
+ this.core.renderEmptyTable(tbody, 4, 'No target lists configured');
+ return;
+ }
+
+ tbody.innerHTML = rows
+ .map(
+ row => `
+ ${this.core.escapeHtml(row.name)}
+ ${this.core.escapeHtml(row.url || 'N/A')}
+ ${row.enabled ? this.core.renderBadge('success', 'ENABLED') : this.core.renderBadge('error', 'DISABLED')}
+
+ `
+ )
+ .join('');
+ });
+ }
+
+ async readAdblockFastConfig() {
+ try {
+ const [status, result] = await this.core.uciGet('adblock-fast');
+ if (status === 0 && result?.values) return result;
+ } catch {}
+ try {
+ const [status, result] = await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: ['-c', 'uci -q show adblock-fast 2>/dev/null || true']
+ });
+ if (status !== 0 || !result?.stdout) return null;
+ return { values: this.parseUciShowToConfig(String(result.stdout || ''), 'adblock-fast') || null };
+ } catch {
+ return null;
+ }
+ }
+
+ parseUciShowToConfig(output, packageName = 'adblock-fast') {
+ const cfg = {};
+ const lines = String(output || '')
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean);
+
+ for (const line of lines) {
+ const escaped = packageName.replace('-', '\\-');
+ const m = line.match(new RegExp(`^${escaped}\\.([^.]+)\\.([^=]+)=(.*)$`));
+ if (!m) continue;
+ const section = m[1];
+ const key = m[2];
+ const value = this.stripOuterQuotes(m[3]);
+ if (!cfg[section]) cfg[section] = {};
+ if (key === '') continue;
+ cfg[section][key] = value;
+ }
+ return Object.keys(cfg).length > 0 ? cfg : null;
+ }
+
+ stripOuterQuotes(value) {
+ const raw = String(value ?? '').trim();
+ if (
+ (raw.startsWith("'") && raw.endsWith("'")) ||
+ (raw.startsWith('"') && raw.endsWith('"'))
+ ) {
+ return raw.slice(1, -1);
+ }
+ return raw;
+ }
+
+ isEnabledValue(value) {
+ const v = this.stripOuterQuotes(value).toLowerCase();
+ return v === '1' || v === 'true' || v === 'on' || v === 'enabled' || v === 'yes';
+ }
+
+ async saveAdblockSettings() {
+ const enabled = String(document.getElementById('adblock-enabled')?.value || '0') === '1' ? '1' : '0';
+ const configUpdate = String(document.getElementById('adblock-config-update')?.value || '0') === '1' ? '1' : '0';
+
+ try {
+ let section = 'config';
+ const [status, result] = await this.core.uciGet('adblock-fast', 'config');
+ if (status !== 0 || !result?.values) {
+ const [addStatus, addResult] = await this.core.uciAdd('adblock-fast', 'adblock-fast', 'config');
+ if (addStatus !== 0 || !addResult?.section) throw new Error('Unable to create adblock-fast section');
+ section = addResult.section;
+ }
+
+ await this.core.uciSet('adblock-fast', section, {
+ enabled,
+ config_update_enabled: configUpdate
+ });
+ await this.core.uciCommit('adblock-fast');
+ await this.reloadAdblockService();
+ this.core.showToast('Adblock-Fast settings saved', 'success');
+ await this.loadAdblock();
+ } catch {
+ this.core.showToast('Failed to save Adblock-Fast settings', 'error');
+ }
+ }
+
+ async addAdblockTargetList() {
+ const name = String(document.getElementById('adblock-new-list-name')?.value || '').trim();
+ const url = String(document.getElementById('adblock-new-list-url')?.value || '').trim();
+ const enabled = String(document.getElementById('adblock-new-list-enabled')?.value || '1') === '1' ? '1' : '0';
+
+ if (!name) {
+ this.core.showToast('Target list name is required', 'error');
+ return;
+ }
+ if (url && !/^https?:\/\/\S+/i.test(url)) {
+ this.core.showToast('Enter a valid target list URL', 'error');
+ return;
+ }
+
+ try {
+ const [status, result] = await this.core.uciAdd('adblock-fast', 'file_url');
+ if (status !== 0 || !result?.section) throw new Error('Unable to create adblock-fast target list');
+ await this.core.uciSet('adblock-fast', result.section, {
+ '.type': 'file_url',
+ name,
+ url,
+ action: 'block',
+ enabled
+ });
+ await this.core.uciCommit('adblock-fast');
+ await this.reloadAdblockService();
+ this.core.showToast('Target list added', 'success');
+ document.getElementById('adblock-new-list-name').value = '';
+ document.getElementById('adblock-new-list-url').value = '';
+ document.getElementById('adblock-new-list-enabled').value = '1';
+ await this.loadAdblock();
+ } catch {
+ this.core.showToast('Failed to add target list', 'error');
+ }
+ }
+
+ async deleteAdblockTargetList(section) {
+ if (!section) return;
+ if (!confirm('Delete this target list?')) return;
+ try {
+ await this.core.uciDelete('adblock-fast', String(section));
+ await this.core.uciCommit('adblock-fast');
+ await this.reloadAdblockService();
+ this.core.showToast('Target list deleted', 'success');
+ await this.loadAdblock();
+ } catch {
+ this.core.showToast('Failed to delete target list', 'error');
+ }
+ }
+
+ async toggleAdblockTargetList(section) {
+ if (!section) return;
+ try {
+ const [status, result] = await this.core.uciGet('adblock-fast', String(section));
+ if (status !== 0 || !result?.values) throw new Error('Target list section not found');
+ const current = this.isEnabledValue(result.values.enabled ?? '0') ? '1' : '0';
+ const next = current === '1' ? '0' : '1';
+ await this.core.uciSet('adblock-fast', String(section), { enabled: next });
+ await this.core.uciCommit('adblock-fast');
+ await this.reloadAdblockService();
+ this.core.showToast(`Target list ${next === '1' ? 'enabled' : 'disabled'}`, 'success');
+ await this.loadAdblock();
+ } catch {
+ this.core.showToast('Failed to toggle target list', 'error');
+ }
+ }
+
+ async reloadAdblockService() {
+ await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: [
+ '-c',
+ '/etc/init.d/adblock-fast reload 2>/dev/null || ' +
+ '/etc/init.d/adblock-fast restart 2>/dev/null || ' +
+ '/etc/init.d/adblock-fast start 2>/dev/null || true'
+ ]
+ });
+ }
+
async loadDDNS() {
await this.core.loadResource('ddns-table', 6, 'ddns', async () => {
const [status, result] = await this.core.uciGet('ddns');
if (status !== 0 || !result?.values) throw new Error('No data');
- const services = this.core.filterUciSections(result.values, 'service');
-
- this.core.renderTable(
- '#ddns-table',
- services,
- 6,
- 'No DDNS services configured',
- s => `
+ const services = Object.entries(result.values)
+ .filter(([, v]) => v['.type'] === 'service')
+ .map(([k, v]) => ({ section: k, ...v }));
+
+ const tbody = document.querySelector('#ddns-table tbody');
+ if (!tbody) return;
+ if (services.length === 0) {
+ this.core.renderEmptyTable(tbody, 6, 'No DDNS services configured');
+ return;
+ }
+ tbody.innerHTML = services
+ .map(
+ s => `
${this.core.escapeHtml(s.section)}
${this.core.escapeHtml(s.lookup_host || s.domain || 'N/A')}
${this.core.escapeHtml(s.service_name || 'Custom')}
@@ -615,36 +1169,74 @@ export default class NetworkModule {
${this.core.renderStatusBadge(s.enabled === '1')}
${this.core.renderActionButtons(s.section)}
`
- );
+ )
+ .join('');
});
}
async editDDNS(id) {
- document.getElementById('edit-ddns-name').value = id;
- await this.core.uciEdit('ddns', id, DDNS_FIELDS, 'ddns-modal', 'edit-ddns-section');
+ try {
+ const [status, result] = await this.core.uciGet('ddns', id);
+ if (status !== 0 || !result?.values) throw new Error('Not found');
+ const c = result.values;
+ document.getElementById('edit-ddns-section').value = id;
+ document.getElementById('edit-ddns-name').value = id;
+ document.getElementById('edit-ddns-service').value = c.service_name || 'cloudflare.com-v4';
+ document.getElementById('edit-ddns-hostname').value = c.lookup_host || c.domain || '';
+ document.getElementById('edit-ddns-username').value = c.username || '';
+ document.getElementById('edit-ddns-password').value = c.password || '';
+ document.getElementById('edit-ddns-check-interval').value = c.check_interval || '10';
+ document.getElementById('edit-ddns-enabled').value = c.enabled || '0';
+ this.core.openModal('ddns-modal');
+ } catch {
+ this.core.showToast('Failed to load DDNS service', 'error');
+ }
}
- saveDDNS() {
- this.core.uciSave({
- config: 'ddns',
- uciType: 'service',
- modalId: 'ddns-modal',
- sectionIdField: 'edit-ddns-section',
- sectionNameField: 'edit-ddns-name',
- fieldMap: DDNS_FIELDS,
- defaults: {
- ip_source: 'network',
- ip_network: 'wan',
- interface: 'wan',
- use_https: '1'
- },
- reloadFn: () => this.loadDDNS(),
- successMsg: 'DDNS service saved'
- });
+ async saveDDNS() {
+ const section = document.getElementById('edit-ddns-section').value;
+ const name = document.getElementById('edit-ddns-name').value;
+ const values = {
+ service_name: document.getElementById('edit-ddns-service').value,
+ lookup_host: document.getElementById('edit-ddns-hostname').value,
+ domain: document.getElementById('edit-ddns-hostname').value,
+ username: document.getElementById('edit-ddns-username').value,
+ password: document.getElementById('edit-ddns-password').value,
+ check_interval: document.getElementById('edit-ddns-check-interval').value,
+ enabled: document.getElementById('edit-ddns-enabled').value,
+ ip_source: 'network',
+ ip_network: 'wan',
+ interface: 'wan',
+ use_https: '1'
+ };
+ try {
+ if (section) {
+ await this.core.uciSet('ddns', section, values);
+ } else {
+ const sectionName = name || null;
+ const [, res] = await this.core.uciAdd('ddns', 'service', sectionName);
+ if (!res?.section) throw new Error('Failed to create section');
+ await this.core.uciSet('ddns', res.section, values);
+ }
+ await this.core.uciCommit('ddns');
+ this.core.closeModal('ddns-modal');
+ this.core.showToast('DDNS service saved', 'success');
+ this.loadDDNS();
+ } catch {
+ this.core.showToast('Failed to save DDNS service', 'error');
+ }
}
- deleteDDNS(id) {
- this.core.uciDeleteEntry('ddns', id, 'Delete this DDNS service?', () => this.loadDDNS());
+ async deleteDDNS(id) {
+ if (!confirm('Delete this DDNS service?')) return;
+ try {
+ await this.core.uciDelete('ddns', id);
+ await this.core.uciCommit('ddns');
+ this.core.showToast('DDNS service deleted', 'success');
+ this.loadDDNS();
+ } catch {
+ this.core.showToast('Failed to delete DDNS service', 'error');
+ }
}
async loadQoS() {
@@ -661,13 +1253,19 @@ export default class NetworkModule {
el('qos-upload').value = iface[1].upload || '';
}
- const rules = this.core.filterUciSections(config, 'classify');
- this.core.renderTable(
- '#qos-rules-table',
- rules,
- 6,
- 'No QoS rules',
- r => `
+ const rules = Object.entries(config)
+ .filter(([, v]) => v['.type'] === 'classify')
+ .map(([k, v]) => ({ section: k, ...v }));
+
+ const tbody = document.querySelector('#qos-rules-table tbody');
+ if (!tbody) return;
+ if (rules.length === 0) {
+ this.core.renderEmptyTable(tbody, 6, 'No QoS rules');
+ return;
+ }
+ tbody.innerHTML = rules
+ .map(
+ r => `
${this.core.escapeHtml(r.section)}
${this.core.escapeHtml(r.target || 'Normal')}
${this.core.escapeHtml(r.proto || 'Any')}
@@ -675,7 +1273,8 @@ export default class NetworkModule {
${this.core.escapeHtml(r.srchost || 'Any')}
${this.core.renderActionButtons(r.section)}
`
- );
+ )
+ .join('');
});
}
@@ -698,24 +1297,57 @@ export default class NetworkModule {
}
async editQoSRule(id) {
- document.getElementById('edit-qos-rule-name').value = id;
- await this.core.uciEdit('qos', id, QOS_RULE_FIELDS, 'qos-rule-modal', 'edit-qos-rule-section');
+ try {
+ const [status, result] = await this.core.uciGet('qos', id);
+ if (status !== 0 || !result?.values) throw new Error('Not found');
+ const c = result.values;
+ document.getElementById('edit-qos-rule-section').value = id;
+ document.getElementById('edit-qos-rule-name').value = id;
+ document.getElementById('edit-qos-rule-priority').value = c.target || 'Normal';
+ document.getElementById('edit-qos-rule-proto').value = c.proto || '';
+ document.getElementById('edit-qos-rule-ports').value = c.ports || '';
+ document.getElementById('edit-qos-rule-srchost').value = c.srchost || '';
+ this.core.openModal('qos-rule-modal');
+ } catch {
+ this.core.showToast('Failed to load QoS rule', 'error');
+ }
}
- saveQoSRule() {
- this.core.uciSave({
- config: 'qos',
- uciType: 'classify',
- modalId: 'qos-rule-modal',
- sectionIdField: 'edit-qos-rule-section',
- fieldMap: QOS_RULE_FIELDS,
- reloadFn: () => this.loadQoS(),
- successMsg: 'QoS rule saved'
- });
+ async saveQoSRule() {
+ const section = document.getElementById('edit-qos-rule-section').value;
+ const values = {
+ target: document.getElementById('edit-qos-rule-priority').value,
+ proto: document.getElementById('edit-qos-rule-proto').value,
+ ports: document.getElementById('edit-qos-rule-ports').value,
+ srchost: document.getElementById('edit-qos-rule-srchost').value
+ };
+ try {
+ if (section) {
+ await this.core.uciSet('qos', section, values);
+ } else {
+ const [, res] = await this.core.uciAdd('qos', 'classify');
+ if (!res?.section) throw new Error('Failed to create section');
+ await this.core.uciSet('qos', res.section, values);
+ }
+ await this.core.uciCommit('qos');
+ this.core.closeModal('qos-rule-modal');
+ this.core.showToast('QoS rule saved', 'success');
+ this.loadQoS();
+ } catch {
+ this.core.showToast('Failed to save QoS rule', 'error');
+ }
}
- deleteQoSRule(id) {
- this.core.uciDeleteEntry('qos', id, 'Delete this QoS rule?', () => this.loadQoS());
+ async deleteQoSRule(id) {
+ if (!confirm('Delete this QoS rule?')) return;
+ try {
+ await this.core.uciDelete('qos', id);
+ await this.core.uciCommit('qos');
+ this.core.showToast('QoS rule deleted', 'success');
+ this.loadQoS();
+ } catch {
+ this.core.showToast('Failed to delete QoS rule', 'error');
+ }
}
async loadVPN() {
@@ -740,13 +1372,20 @@ export default class NetworkModule {
.filter(([, v]) => v['.type']?.startsWith('wireguard_'))
.map(([k, v]) => ({ section: k, ...v }));
- this.core.renderTable('#wg-peers-table', peers, 6, 'No WireGuard peers configured', p => {
- const pubKey = p.public_key ? this.core.escapeHtml(p.public_key.substring(0, 20)) + '...' : 'N/A';
- const endpoint =
- p.endpoint_host && p.endpoint_port
- ? `${this.core.escapeHtml(p.endpoint_host)}:${this.core.escapeHtml(String(p.endpoint_port))}`
- : 'N/A';
- return `
+ const tbody = document.querySelector('#wg-peers-table tbody');
+ if (!tbody) return;
+ if (peers.length === 0) {
+ this.core.renderEmptyTable(tbody, 6, 'No WireGuard peers configured');
+ return;
+ }
+ tbody.innerHTML = peers
+ .map(p => {
+ const pubKey = p.public_key ? this.core.escapeHtml(p.public_key.substring(0, 20)) + '...' : 'N/A';
+ const endpoint =
+ p.endpoint_host && p.endpoint_port
+ ? `${this.core.escapeHtml(p.endpoint_host)}:${this.core.escapeHtml(String(p.endpoint_port))}`
+ : 'N/A';
+ return `
${this.core.escapeHtml(p.description || p.section)}
${pubKey}
${this.core.escapeHtml(Array.isArray(p.allowed_ips) ? p.allowed_ips.join(', ') : p.allowed_ips || 'N/A')}
@@ -754,26 +1393,24 @@ export default class NetworkModule {
${this.core.renderBadge('success', 'CONFIGURED')}
${this.core.renderActionButtons(p.section)}
`;
- });
+ })
+ .join('');
});
}
async saveWgConfig() {
try {
const ifaceName = document.getElementById('wg-interface').value || 'wg0';
- const disabled = document.getElementById('wg-enabled').value === '0';
- const addr = (document.getElementById('wg-address').value || '').trim();
- if (!addr) {
- this.core.showToast('WireGuard address is required', 'error');
- return;
- }
const values = {
proto: 'wireguard',
listen_port: document.getElementById('wg-port').value,
private_key: document.getElementById('wg-private-key').value,
- addresses: [addr],
- disabled: disabled ? '1' : '0'
+ addresses: [document.getElementById('wg-address').value]
};
+ const disabled = document.getElementById('wg-enabled').value === '0';
+ if (disabled) values.disabled = '1';
+ else values.disabled = '0';
+
await this.core.uciSet('network', ifaceName, values);
await this.core.uciCommit('network');
this.core.showToast('WireGuard configuration saved', 'success');
@@ -824,74 +1461,240 @@ export default class NetworkModule {
}
}
- editWgPeer(id) {
- this.core.uciEdit(
- 'network',
- id,
- {
- 'edit-wg-peer-name': 'description',
- 'edit-wg-peer-public-key': 'public_key',
- 'edit-wg-peer-allowed-ips': 'allowed_ips',
- 'edit-wg-peer-keepalive': 'persistent_keepalive',
- 'edit-wg-peer-preshared-key': 'preshared_key'
- },
- 'wg-peer-modal',
- 'edit-wg-peer-section'
- );
+ async editWgPeer(id) {
+ try {
+ const [status, result] = await this.core.uciGet('network', id);
+ if (status !== 0 || !result?.values) throw new Error('Not found');
+ const c = result.values;
+ document.getElementById('edit-wg-peer-section').value = id;
+ document.getElementById('edit-wg-peer-name').value = c.description || '';
+ document.getElementById('edit-wg-peer-public-key').value = c.public_key || '';
+ document.getElementById('edit-wg-peer-allowed-ips').value = Array.isArray(c.allowed_ips)
+ ? c.allowed_ips.join(', ')
+ : c.allowed_ips || '';
+ document.getElementById('edit-wg-peer-keepalive').value = c.persistent_keepalive || '';
+ document.getElementById('edit-wg-peer-preshared-key').value = c.preshared_key || '';
+ this.core.openModal('wg-peer-modal');
+ } catch {
+ this.core.showToast('Failed to load peer config', 'error');
+ }
}
- saveWgPeer() {
+ async saveWgPeer() {
+ const section = document.getElementById('edit-wg-peer-section').value;
const ifaceName = document.getElementById('wg-interface').value || 'wg0';
- const allowedIpsRaw = document.getElementById('edit-wg-peer-allowed-ips')?.value || '';
- const allowed_ips = allowedIpsRaw
- .split(/[,\s]+/)
- .map(s => s.trim())
- .filter(Boolean);
- this.core.uciSave({
- config: 'network',
- uciType: `wireguard_${ifaceName}`,
- modalId: 'wg-peer-modal',
- sectionIdField: 'edit-wg-peer-section',
- fieldMap: {
- 'edit-wg-peer-name': 'description',
- 'edit-wg-peer-public-key': 'public_key',
- 'edit-wg-peer-keepalive': 'persistent_keepalive',
- 'edit-wg-peer-preshared-key': 'preshared_key'
- },
- defaults: { allowed_ips },
- reloadFn: () => this.loadVPN(),
- successMsg: 'WireGuard peer saved'
- });
+ const values = {
+ description: document.getElementById('edit-wg-peer-name').value,
+ public_key: document.getElementById('edit-wg-peer-public-key').value,
+ allowed_ips: document
+ .getElementById('edit-wg-peer-allowed-ips')
+ .value.split(/[,\s]+/)
+ .filter(Boolean),
+ persistent_keepalive: document.getElementById('edit-wg-peer-keepalive').value,
+ preshared_key: document.getElementById('edit-wg-peer-preshared-key').value
+ };
+ try {
+ if (section) {
+ await this.core.uciSet('network', section, values);
+ } else {
+ const [, res] = await this.core.uciAdd('network', `wireguard_${ifaceName}`);
+ if (!res?.section) throw new Error('Failed to create section');
+ await this.core.uciSet('network', res.section, values);
+ }
+ await this.core.uciCommit('network');
+ this.core.closeModal('wg-peer-modal');
+ this.core.showToast('WireGuard peer saved', 'success');
+ this.loadVPN();
+ } catch {
+ this.core.showToast('Failed to save WireGuard peer', 'error');
+ }
}
- deleteWgPeer(id) {
- this.core.uciDeleteEntry('network', id, 'Delete this WireGuard peer?', () => this.loadVPN());
+ async deleteWgPeer(id) {
+ if (!confirm('Delete this WireGuard peer?')) return;
+ try {
+ await this.core.uciDelete('network', id);
+ await this.core.uciCommit('network');
+ this.core.showToast('Peer deleted', 'success');
+ this.loadVPN();
+ } catch {
+ this.core.showToast('Failed to delete peer', 'error');
+ }
}
async loadDiagnostics() {
if (!this.core.isFeatureEnabled('diagnostics')) return;
- await this.core.loadResource('dhcp-clients-table', 4, null, async () => {
- let leases = [];
- try {
- const [s, r] = await this.core.ubusCall('luci-rpc', 'getDHCPLeases', {});
- if (s === 0 && r?.dhcp_leases) leases = r.dhcp_leases;
- } catch {}
+ await this.loadWoLDeviceOptions();
+ }
- this.core.renderTable(
- '#dhcp-clients-table',
- leases,
- 4,
- 'No DHCP clients',
- l => `
- ${this.core.escapeHtml(l.ipaddr || 'N/A')}
- ${this.core.escapeHtml(l.macaddr || 'N/A')}
- ${this.core.escapeHtml(l.hostname || 'Unknown')}
- ${l.expires > 0 ? l.expires + 's' : 'Permanent'}
- `
- );
+ async loadWoLDeviceOptions() {
+ const list = document.getElementById('wol-device-list');
+ const hint = document.getElementById('wol-hint');
+ if (!list) return;
+
+ let leases = [];
+ try {
+ const [status, result] = await this.core.ubusCall('luci-rpc', 'getDHCPLeases', {});
+ if (status === 0 && Array.isArray(result?.dhcp_leases)) {
+ leases = result.dhcp_leases;
+ }
+ } catch {}
+
+ const options = leases
+ .filter(lease => lease?.macaddr)
+ .map(lease => {
+ const hostname = String(lease.hostname || 'Unknown');
+ const ipaddr = String(lease.ipaddr || 'N/A');
+ const macaddr = String(lease.macaddr || '').toLowerCase();
+ return {
+ value: macaddr,
+ label: `${hostname} (${ipaddr})`
+ };
+ })
+ .sort((a, b) => a.label.localeCompare(b.label));
+
+ list.innerHTML = '';
+ for (const option of options) {
+ const el = document.createElement('option');
+ el.value = option.value;
+ el.label = option.label;
+ list.appendChild(el);
+ }
+
+ if (hint) {
+ if (options.length > 0) {
+ hint.textContent = `Loaded ${options.length} DHCP lease device(s). Pick from suggestions or enter a MAC manually.`;
+ } else {
+ hint.textContent = 'No DHCP lease devices found. Enter a MAC address manually.';
+ }
+ }
+ }
+
+ async loadConnections() {
+ this.startConnectionsAutoRefresh();
+ await this.core.loadResource('network-active-connections-table', 4, null, async () => {
+ await this.renderConnectionsTable();
});
}
+ async renderConnectionsTable() {
+ const tbody = document.querySelector('#network-active-connections-table tbody');
+ if (!tbody) return;
+
+ let connections = await this.fetchConnections();
+ if (!Array.isArray(connections)) connections = [];
+
+ if (connections.length === 0) {
+ this.core.renderEmptyTable(tbody, 4, 'No active conntrack connections');
+ return;
+ }
+
+ tbody.innerHTML = connections
+ .map(conn => {
+ const source = conn.source || 'N/A';
+ const destination = conn.destination || 'N/A';
+ const protocol = (conn.protocol || 'N/A').toUpperCase();
+ const status = conn.state || 'ACTIVE';
+ return `
+ ${this.core.escapeHtml(source)}
+ ${this.core.escapeHtml(destination)}
+ ${this.core.escapeHtml(protocol)}
+ ${this.renderConntrackStateBadge(status)}
+ `;
+ })
+ .join('');
+ }
+
+ startConnectionsAutoRefresh() {
+ if (this.connectionsRefreshTimer) return;
+ this.connectionsRefreshTimer = setInterval(async () => {
+ if (document.hidden) return;
+ if (!this.core.currentRoute || !this.core.currentRoute.startsWith('/network/connections')) return;
+ if (this.isRefreshingConnections) return;
+ this.isRefreshingConnections = true;
+ try {
+ await this.renderConnectionsTable();
+ } finally {
+ this.isRefreshingConnections = false;
+ }
+ }, 5000);
+ }
+
+ stopConnectionsAutoRefresh() {
+ if (!this.connectionsRefreshTimer) return;
+ clearInterval(this.connectionsRefreshTimer);
+ this.connectionsRefreshTimer = null;
+ }
+
+ async fetchConnections() {
+ try {
+ const [status, result] = await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: [
+ '-c',
+ 'if command -v conntrack >/dev/null 2>&1; then conntrack -L 2>/dev/null | head -n 500; ' +
+ 'elif [ -r /proc/net/nf_conntrack ]; then head -n 500 /proc/net/nf_conntrack 2>/dev/null; ' +
+ 'elif [ -r /proc/net/ip_conntrack ]; then head -n 500 /proc/net/ip_conntrack 2>/dev/null; ' +
+ 'fi'
+ ]
+ });
+ if (status !== 0 || !result?.stdout) return [];
+ return this.parseConntrackRows(String(result.stdout || ''));
+ } catch {
+ return [];
+ }
+ }
+
+ parseConntrackRows(raw) {
+ return String(raw || '')
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean)
+ .map(line => this.parseConntrackLine(line))
+ .filter(Boolean);
+ }
+
+ parseConntrackLine(line) {
+ const text = String(line || '').trim();
+ if (!text) return null;
+
+ const tokens = text.split(/\s+/);
+ let protocol = '';
+ for (const token of tokens) {
+ if (/^(tcp|udp|icmp|icmpv6|sctp|gre|dccp)$/i.test(token)) {
+ protocol = token.toLowerCase();
+ break;
+ }
+ }
+
+ const stateMatch = text.match(
+ /\b(ESTABLISHED|SYN_SENT|SYN_RECV|FIN_WAIT|TIME_WAIT|CLOSE|CLOSE_WAIT|LAST_ACK|LISTEN|CLOSING|UNREPLIED|ASSURED)\b/i
+ );
+ const srcMatches = [...text.matchAll(/\bsrc=([^\s]+)/g)];
+ const dstMatches = [...text.matchAll(/\bdst=([^\s]+)/g)];
+ const sportMatches = [...text.matchAll(/\bsport=([^\s]+)/g)];
+ const dportMatches = [...text.matchAll(/\bdport=([^\s]+)/g)];
+
+ const src = srcMatches[0]?.[1] || '';
+ const dst = dstMatches[0]?.[1] || '';
+ const sport = sportMatches[0]?.[1] || '';
+ const dport = dportMatches[0]?.[1] || '';
+
+ return {
+ source: src ? `${src}${sport ? `:${sport}` : ''}` : 'N/A',
+ destination: dst ? `${dst}${dport ? `:${dport}` : ''}` : 'N/A',
+ protocol: protocol || 'unknown',
+ state: stateMatch ? stateMatch[1].toUpperCase() : 'ACTIVE'
+ };
+ }
+
+ renderConntrackStateBadge(state) {
+ const s = String(state || 'ACTIVE').toUpperCase();
+ if (s === 'ESTABLISHED' || s === 'ASSURED') return this.core.renderBadge('success', s);
+ if (s === 'UNREPLIED' || s === 'SYN_SENT' || s === 'SYN_RECV') return this.core.renderBadge('warning', s);
+ return this.core.renderBadge('info', s);
+ }
+
async runDiagnostic(type) {
const hostInput = document.getElementById(`${type}-host`);
const output = document.getElementById(`${type}-output`);
@@ -911,12 +1714,39 @@ export default class NetworkModule {
output.innerHTML = '
+ ${this.core.escapeHtml(rule.name)}
+ ${this.core.escapeHtml(rule.target || 'Default')}
+ ${this.core.escapeHtml(rule.proto || 'all')}
+ ${this.core.escapeHtml(rule.srchost || 'any')}
+ ${statusBadge}
+
+ `;
+ }
+
+ renderQoSTable(rules) {
+ return rules.map(rule => this.renderQoSRow(rule)).join('');
+ }
+
+ updateQoSTable(rules) {
+ const tbody = document.querySelector('#qos-table tbody');
+ if (!tbody) return;
+
+ if (rules.length === 0) {
+ this.core.renderEmptyTable(tbody, 5, 'No QoS rules configured');
+ return;
+ }
+
+ tbody.innerHTML = this.renderQoSTable(rules);
+ }
+
+ async loadQoS() {
+ if (!this.core.isFeatureEnabled('qos')) return;
+
+ try {
+ const config = await this.fetchQoSConfig();
+ const rules = this.parseQoSRules(config);
+ this.updateQoSTable(rules);
+ } catch (err) {
+ console.error('Failed to load QoS:', err);
+ const tbody = document.querySelector('#qos-table tbody');
+ if (tbody) {
+ this.core.renderEmptyTable(tbody, 5, 'QoS not configured');
+ }
+ }
+ }
+
+ async fetchDDNSConfig() {
+ const [status, result] = await this.core.uciGet('ddns');
+
+ if (status !== 0 || !result?.values) {
+ throw new Error('DDNS not configured');
+ }
+
+ return result.values;
+ }
+
+ parseDDNSServices(config) {
+ return Object.entries(config)
+ .filter(([key, val]) => val['.type'] === 'service')
+ .map(([key, val]) => ({
+ name: key,
+ ...val
+ }));
+ }
+
+ renderDDNSRow(service) {
+ const enabled = service.enabled === '1';
+ const statusBadge = enabled
+ ? this.core.renderBadge('success', 'ENABLED')
+ : this.core.renderBadge('error', 'DISABLED');
+
+ return `
+
+ ${this.core.escapeHtml(service.name)}
+ ${this.core.escapeHtml(service.service_name || 'Custom')}
+ ${this.core.escapeHtml(service.domain || 'N/A')}
+ ${statusBadge}
+
+ `;
+ }
+
+ renderDDNSTable(services) {
+ return services.map(service => this.renderDDNSRow(service)).join('');
+ }
+
+ updateDDNSTable(services) {
+ const tbody = document.querySelector('#ddns-table tbody');
+ if (!tbody) return;
+
+ if (services.length === 0) {
+ this.core.renderEmptyTable(tbody, 4, 'No DDNS services configured');
+ return;
+ }
+
+ tbody.innerHTML = this.renderDDNSTable(services);
+ }
+
+ async loadDDNS() {
+ if (!this.core.isFeatureEnabled('ddns')) return;
+
+ try {
+ const config = await this.fetchDDNSConfig();
+ const services = this.parseDDNSServices(config);
+ this.updateDDNSTable(services);
+ } catch (err) {
+ console.error('Failed to load DDNS:', err);
+ const tbody = document.querySelector('#ddns-table tbody');
+ if (tbody) {
+ this.core.renderEmptyTable(tbody, 4, 'DDNS not configured');
+ }
+ }
+ }
+}
diff --git a/moci/js/modules/system.js b/moci/js/modules/system.js
index 19801a5..d3564bc 100644
--- a/moci/js/modules/system.js
+++ b/moci/js/modules/system.js
@@ -6,6 +6,11 @@ export default class SystemModule {
this.cronRaw = '';
this.sshKeysRaw = '';
this.firmwareFile = null;
+ this.packages = [];
+ this.filteredPackages = [];
+ this.packagesPage = 0;
+ this.packagesPageSize = 50;
+ this.packagesQuery = '';
this.core.registerRoute('/system', (path, subPaths) => {
const pageElement = document.getElementById('system-page');
@@ -34,30 +39,35 @@ export default class SystemModule {
}
setupHandlers() {
- const buttons = {
- 'save-general-btn': () => this.saveGeneral(),
- 'change-password-btn': () => this.changePassword(),
- 'backup-btn': () => this.createBackup(),
- 'reset-btn': () => this.factoryReset(),
- 'reboot-btn': () => this.rebootSystem(),
- 'restart-network-btn': () => this.core.serviceReload('network'),
- 'restart-firewall-btn': () => this.core.serviceReload('firewall'),
- 'add-cron-btn': () => {
- this.core.resetModal('cron-modal');
- this.core.openModal('cron-modal');
- },
- 'add-ssh-key-btn': () => {
- this.core.resetModal('ssh-key-modal');
- document.getElementById('parsed-keys-preview').style.display = 'none';
- document.getElementById('save-ssh-keys-btn').style.display = 'none';
- this.core.openModal('ssh-key-modal');
- },
- 'parse-keys-btn': () => this.parseSSHKeyInput()
- };
-
- for (const [id, handler] of Object.entries(buttons)) {
- document.getElementById(id)?.addEventListener('click', handler);
- }
+ document.getElementById('save-general-btn')?.addEventListener('click', () => this.saveGeneral());
+ document.getElementById('sync-browser-time-btn')?.addEventListener('click', () => this.syncBrowserTime());
+ document.getElementById('save-moci-config-btn')?.addEventListener('click', () => this.saveMociConfig());
+ document.getElementById('change-password-btn')?.addEventListener('click', () => this.changePassword());
+ document.getElementById('backup-btn')?.addEventListener('click', () => this.createBackup());
+ document.getElementById('reset-btn')?.addEventListener('click', () => this.factoryReset());
+ document.getElementById('reboot-btn')?.addEventListener('click', () => this.rebootSystem());
+ document.getElementById('packages-search')?.addEventListener('input', event => {
+ this.packagesQuery = String(event?.target?.value || '')
+ .trim()
+ .toLowerCase();
+ this.packagesPage = 0;
+ this.applyPackageFilter();
+ this.renderPackagesTable();
+ });
+ document.getElementById('packages-prev-btn')?.addEventListener('click', () => {
+ this.packagesPage = Math.max(0, this.packagesPage - 1);
+ this.renderPackagesTable();
+ });
+ document.getElementById('packages-next-btn')?.addEventListener('click', () => {
+ this.packagesPage += 1;
+ this.renderPackagesTable();
+ });
+ document
+ .getElementById('restart-network-btn')
+ ?.addEventListener('click', () => this.core.serviceReload('network'));
+ document
+ .getElementById('restart-firewall-btn')
+ ?.addEventListener('click', () => this.core.serviceReload('firewall'));
this.core.setupModal({
modalId: 'cron-modal',
@@ -75,18 +85,42 @@ export default class SystemModule {
saveHandler: () => this.saveSSHKeys()
});
- const delegations = {
- 'cron-table': { edit: id => this.editCronEntry(id), delete: id => this.deleteCronEntry(id) },
- 'ssh-keys-table': { delete: id => this.deleteSSHKey(id) },
- 'services-table': { toggle: id => this.toggleService(id) }
- };
+ document.getElementById('add-cron-btn')?.addEventListener('click', () => {
+ this.openCronCreateModal();
+ });
- for (const [tableId, handlers] of Object.entries(delegations)) {
- const cleanup = this.core.delegateActions(tableId, handlers);
- if (cleanup) this.cleanups.push(cleanup);
- }
+ document.getElementById('add-ssh-key-btn')?.addEventListener('click', () => {
+ this.core.resetModal('ssh-key-modal');
+ document.getElementById('parsed-keys-preview').style.display = 'none';
+ document.getElementById('save-ssh-keys-btn').style.display = 'none';
+ this.core.openModal('ssh-key-modal');
+ });
+
+ document.getElementById('parse-keys-btn')?.addEventListener('click', () => this.parseSSHKeyInput());
+
+ const cronCleanup = this.core.delegateActions('cron-table', {
+ edit: id => this.editCronEntry(id),
+ delete: id => this.deleteCronEntry(id)
+ });
+ if (cronCleanup) this.cleanups.push(cronCleanup);
+
+ const sshCleanup = this.core.delegateActions('ssh-keys-table', {
+ delete: id => this.deleteSSHKey(id)
+ });
+ if (sshCleanup) this.cleanups.push(sshCleanup);
+
+ const servicesCleanup = this.core.delegateActions('services-table', {
+ toggle: id => this.toggleService(id)
+ });
+ if (servicesCleanup) this.cleanups.push(servicesCleanup);
this.setupFirmwareUpload();
+
+ // Ensure the MOCI config panel never stays on the static "Loading..." placeholder.
+ this.loadMociConfig().catch(() => {
+ const grid = document.getElementById('moci-features-grid');
+ if (grid) grid.innerHTML = '
+ if (packages.length === 0) {
+ const [execStatus, execResult] = await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: ['-c', 'if command -v apk >/dev/null 2>&1; then apk info -v; fi']
+ });
+ if (execStatus === 0 && execResult?.stdout) {
+ packages = this.parseApkInfoOutput(execResult.stdout);
+ }
+ }
+
+ this.packages = packages;
+ this.applyPackageFilter();
+ this.packagesPage = 0;
+ this.renderPackagesTable();
+ });
+ }
+
+ async updateSoftwareRootSpace() {
+ const labelEl = document.getElementById('software-root-space-label');
+ const fillEl = document.getElementById('software-root-space-fill');
+ if (!labelEl || !fillEl) return;
+
+ try {
+ const usageByMountPoint = await this.readMountUsageByMountPoint();
+ const rootUsage = usageByMountPoint.get('/');
+ if (!rootUsage) throw new Error('root mount usage unavailable');
+
+ const usedPct = Number(String(rootUsage.usePercent || '').replace('%', ''));
+ if (!Number.isFinite(usedPct)) throw new Error('invalid root usage value');
+
+ const clampedUsedPct = Math.max(0, Math.min(100, usedPct));
+ fillEl.style.width = `${clampedUsedPct.toFixed(1)}%`;
+ labelEl.textContent = `Used: ${clampedUsedPct.toFixed(1)}% (${rootUsage.used || 'N/A'} of ${rootUsage.size || 'N/A'})`;
+ } catch {
+ fillEl.style.width = '0%';
+ labelEl.textContent = 'Used: N/A';
+ }
+ }
+
+ parseOpkgStatus(content) {
+ const packages = [];
+ for (const block of String(content || '').split('\n\n')) {
+ const pkg = {};
+ for (const line of block.split('\n')) {
+ if (line.startsWith('Package: ')) pkg.name = line.substring(9);
+ else if (line.startsWith('Version: ')) pkg.version = line.substring(9);
+ }
+ if (pkg.name) packages.push(pkg);
+ }
+ return packages;
+ }
+
+ parseApkInstalledDb(content) {
+ const packages = [];
+ for (const block of String(content || '').split('\n\n')) {
+ let name = '';
+ let version = '';
+ for (const line of block.split('\n')) {
+ if (line.startsWith('P:')) name = line.substring(2).trim();
+ else if (line.startsWith('V:')) version = line.substring(2).trim();
+ }
+ if (name) packages.push({ name, version: version || 'N/A' });
+ }
+ return packages;
+ }
+
+ parseApkInfoOutput(content) {
+ return String(content || '')
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean)
+ .map(line => {
+ const idx = line.lastIndexOf('-');
+ if (idx > 0) {
+ return {
+ name: line.substring(0, idx),
+ version: line.substring(idx + 1) || 'N/A'
+ };
+ }
+ return { name: line, version: 'N/A' };
+ });
+ }
+
+ applyPackageFilter() {
+ const q = this.packagesQuery;
+ if (!q) {
+ this.filteredPackages = [...this.packages];
+ return;
+ }
+ this.filteredPackages = this.packages.filter(pkg =>
+ `${pkg.name || ''} ${pkg.version || ''}`
+ .toLowerCase()
+ .includes(q)
+ );
+ }
+
+ renderPackagesTable() {
+ const tbody = document.querySelector('#packages-table tbody');
+ const infoEl = document.getElementById('packages-page-info');
+ const prevBtn = document.getElementById('packages-prev-btn');
+ const nextBtn = document.getElementById('packages-next-btn');
+ if (!tbody) return;
+
+ const source = Array.isArray(this.filteredPackages) ? this.filteredPackages : [];
+ const total = source.length;
+ if (total === 0) {
+ this.core.renderEmptyTable(tbody, 3, this.packagesQuery ? 'No matching packages' : 'No packages found');
+ if (infoEl) infoEl.textContent = '0-0 of 0';
+ if (prevBtn) prevBtn.disabled = true;
+ if (nextBtn) nextBtn.disabled = true;
+ return;
+ }
+
+ const maxPage = Math.max(0, Math.ceil(total / this.packagesPageSize) - 1);
+ if (this.packagesPage > maxPage) this.packagesPage = maxPage;
+
+ const startIdx = this.packagesPage * this.packagesPageSize;
+ const endIdx = Math.min(total, startIdx + this.packagesPageSize);
+ const pageRows = source.slice(startIdx, endIdx);
+
+ tbody.innerHTML = pageRows
+ .map(
+ p => `
${this.core.escapeHtml(p.name)}
${this.core.escapeHtml(p.version)}
${this.core.renderBadge('success', 'Installed')}
`
- )
- .join('');
- if (packages.length > 100) {
- html += `Showing 100 of ${packages.length} packages `;
- }
- const tbody = document.querySelector('#packages-table tbody');
- if (tbody) {
- if (packages.length === 0) {
- this.core.renderEmptyTable(tbody, 3, 'No packages found');
- } else {
- tbody.innerHTML = html;
- }
- }
- });
+ )
+ .join('');
+
+ if (infoEl) infoEl.textContent = `${startIdx + 1}-${endIdx} of ${total}`;
+ if (prevBtn) prevBtn.disabled = this.packagesPage <= 0;
+ if (nextBtn) nextBtn.disabled = this.packagesPage >= maxPage;
}
async loadStartup() {
@@ -280,17 +579,20 @@ export default class SystemModule {
const [status, result] = await this.core.ubusCall('service', 'list', {});
if (status !== 0 || !result) throw new Error('No data');
- const services = Object.entries(result).map(([name, info]) => ({
- name,
- running: info.instances && Object.keys(info.instances).length > 0
- }));
-
- this.core.renderTable(
- '#services-table',
- services,
- 4,
- 'No services found',
- s => `
+ const services = Object.entries(result).map(([name, info]) => {
+ const running = info.instances && Object.keys(info.instances).length > 0;
+ return { name, running };
+ });
+
+ const tbody = document.querySelector('#services-table tbody');
+ if (!tbody) return;
+ if (services.length === 0) {
+ this.core.renderEmptyTable(tbody, 4, 'No services found');
+ return;
+ }
+ tbody.innerHTML = services
+ .map(
+ s => `
${this.core.escapeHtml(s.name)}
${s.running ? this.core.renderBadge('success', 'RUNNING') : this.core.renderBadge('error', 'STOPPED')}
${this.core.renderBadge('info', 'N/A')}
@@ -302,7 +604,8 @@ export default class SystemModule {
`
- );
+ )
+ .join('');
});
}
@@ -332,31 +635,39 @@ export default class SystemModule {
async loadCron() {
await this.core.loadResource('cron-table', 4, null, async () => {
try {
- const [s, r] = await this.core.ubusCall('file', 'read', { path: '/etc/crontabs/root' });
- this.cronRaw = s === 0 && r?.data ? r.data : '';
+ const [s, r] = await this.core.ubusCall('file', 'read', {
+ path: '/etc/crontabs/root'
+ });
+ if (s === 0 && r?.data) this.cronRaw = r.data;
+ else this.cronRaw = '';
} catch {
this.cronRaw = '';
}
const entries = this.parseCron(this.cronRaw);
- this.core.renderTable(
- '#cron-table',
- entries,
- 4,
- 'No scheduled tasks',
- (e, i) => `
+ const tbody = document.querySelector('#cron-table tbody');
+ if (!tbody) return;
+ if (entries.length === 0) {
+ this.core.renderEmptyTable(tbody, 4, 'No scheduled tasks');
+ return;
+ }
+ tbody.innerHTML = entries
+ .map(
+ (e, i) => `
${this.core.escapeHtml(e.schedule)}
${this.core.escapeHtml(e.command)}
${e.enabled ? this.core.renderBadge('success', 'ENABLED') : this.core.renderBadge('error', 'DISABLED')}
${this.core.renderActionButtons(String(i))}
`
- );
+ )
+ .join('');
});
}
parseCron(data) {
const result = [];
- data.split('\n').forEach((line, rawIndex) => {
+ const lines = data.split('\n');
+ lines.forEach((line, rawIndex) => {
if (!line.trim()) return;
const enabled = !line.trim().startsWith('#');
const clean = line.replace(/^#\s*/, '').trim();
@@ -382,36 +693,45 @@ export default class SystemModule {
const entry = entries[parseInt(index)];
if (!entry) return;
document.getElementById('edit-cron-index').value = index;
- document.getElementById('edit-cron-minute').value = entry.minute;
- document.getElementById('edit-cron-hour').value = entry.hour;
- document.getElementById('edit-cron-day').value = entry.day;
- document.getElementById('edit-cron-month').value = entry.month;
- document.getElementById('edit-cron-weekday').value = entry.weekday;
+ document.getElementById('edit-cron-time').value = this.cronTimeFromEntry(entry);
+ this.applyCronWeekdaySelection(entry.weekday);
document.getElementById('edit-cron-command').value = entry.command;
document.getElementById('edit-cron-enabled').checked = entry.enabled;
+ if (entry.day !== '*' || entry.month !== '*') {
+ this.core.showToast('Editing simplified to weekly/day schedule in this dialog', 'warning');
+ }
this.core.openModal('cron-modal');
}
async saveCronEntry() {
const index = document.getElementById('edit-cron-index').value;
- const minute = document.getElementById('edit-cron-minute').value || '*';
- const hour = document.getElementById('edit-cron-hour').value || '*';
- const day = document.getElementById('edit-cron-day').value || '*';
- const month = document.getElementById('edit-cron-month').value || '*';
- const weekday = document.getElementById('edit-cron-weekday').value || '*';
+ const timeValue = String(document.getElementById('edit-cron-time')?.value || '00:00');
+ const [hourPart, minutePart] = timeValue.split(':');
+ const hour = Number(hourPart);
+ const minute = Number(minutePart);
const command = document.getElementById('edit-cron-command').value.trim();
const enabled = document.getElementById('edit-cron-enabled').checked;
+ const weekdays = this.getSelectedCronWeekdays();
if (!command) {
this.core.showToast('Command is required', 'error');
return;
}
+ if (!Number.isInteger(hour) || hour < 0 || hour > 23 || !Number.isInteger(minute) || minute < 0 || minute > 59) {
+ this.core.showToast('Select a valid time', 'error');
+ return;
+ }
+ if (weekdays.length === 0) {
+ this.core.showToast('Select at least one day', 'error');
+ return;
+ }
- const newLine = `${enabled ? '' : '# '}${minute} ${hour} ${day} ${month} ${weekday} ${command}`;
+ const weekdayExpr = weekdays.length === 7 ? '*' : weekdays.join(',');
+ const newLine = `${enabled ? '' : '# '}${minute} ${hour} * * ${weekdayExpr} ${command}`;
const lines = this.cronRaw.split('\n');
- const entries = this.parseCron(this.cronRaw);
if (index !== '') {
+ const entries = this.parseCron(this.cronRaw);
const entry = entries[parseInt(index)];
if (entry) lines[entry.rawIndex] = newLine;
} else {
@@ -424,6 +744,7 @@ export default class SystemModule {
path: '/etc/crontabs/root',
data: lines.join('\n') + (this.cronRaw.endsWith('\n') ? '' : '\n')
});
+ await this.applyCronChanges();
this.core.closeModal('cron-modal');
this.core.showToast('Cron entry saved', 'success');
this.loadCron();
@@ -432,6 +753,73 @@ export default class SystemModule {
}
}
+ openCronCreateModal() {
+ document.getElementById('edit-cron-index').value = '';
+ document.getElementById('edit-cron-time').value = '00:00';
+ document.getElementById('edit-cron-command').value = '';
+ document.getElementById('edit-cron-enabled').checked = true;
+ this.applyCronWeekdaySelection('*');
+ this.core.openModal('cron-modal');
+ }
+
+ cronTimeFromEntry(entry) {
+ const hour = Number(entry?.hour);
+ const minute = Number(entry?.minute);
+ const h = Number.isInteger(hour) && hour >= 0 && hour <= 23 ? String(hour).padStart(2, '0') : '00';
+ const m = Number.isInteger(minute) && minute >= 0 && minute <= 59 ? String(minute).padStart(2, '0') : '00';
+ return `${h}:${m}`;
+ }
+
+ getSelectedCronWeekdays() {
+ return Array.from(document.querySelectorAll('.cron-dow:checked'))
+ .map(el => Number(el.value))
+ .filter(v => Number.isInteger(v) && v >= 0 && v <= 6)
+ .sort((a, b) => a - b);
+ }
+
+ applyCronWeekdaySelection(expr) {
+ const selected = this.parseCronWeekdayExpression(expr);
+ for (let i = 0; i <= 6; i++) {
+ const cb = document.getElementById(`cron-dow-${i}`);
+ if (cb) cb.checked = selected.has(i);
+ }
+ }
+
+ parseCronWeekdayExpression(expr) {
+ const value = String(expr || '*')
+ .trim()
+ .toLowerCase();
+ const all = new Set([0, 1, 2, 3, 4, 5, 6]);
+ if (!value || value === '*') return all;
+
+ const byName = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
+ const selected = new Set();
+ for (const partRaw of value.split(',')) {
+ const part = partRaw.trim();
+ if (!part) continue;
+
+ if (part.includes('-')) {
+ const [aRaw, bRaw] = part.split('-').map(s => s.trim());
+ const a = /^[0-7]$/.test(aRaw) ? Number(aRaw) % 7 : byName[aRaw];
+ const b = /^[0-7]$/.test(bRaw) ? Number(bRaw) % 7 : byName[bRaw];
+ if (Number.isInteger(a) && Number.isInteger(b)) {
+ if (a <= b) {
+ for (let i = a; i <= b; i++) selected.add(i);
+ } else {
+ for (let i = a; i <= 6; i++) selected.add(i);
+ for (let i = 0; i <= b; i++) selected.add(i);
+ }
+ }
+ continue;
+ }
+
+ if (/^[0-7]$/.test(part)) selected.add(Number(part) % 7);
+ else if (Object.prototype.hasOwnProperty.call(byName, part)) selected.add(byName[part]);
+ }
+
+ return selected.size > 0 ? selected : all;
+ }
+
async deleteCronEntry(index) {
if (!confirm('Delete this scheduled task?')) return;
const entries = this.parseCron(this.cronRaw);
@@ -444,6 +832,7 @@ export default class SystemModule {
path: '/etc/crontabs/root',
data: lines.join('\n') + (this.cronRaw.endsWith('\n') ? '' : '\n')
});
+ await this.applyCronChanges();
this.core.showToast('Task deleted', 'success');
this.loadCron();
} catch {
@@ -451,22 +840,44 @@ export default class SystemModule {
}
}
+ async applyCronChanges() {
+ // Different OpenWrt variants expose either "cron" or "crond".
+ // Try common restart/reload paths, then signal crond directly.
+ await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: [
+ '-c',
+ '/etc/init.d/cron reload 2>/dev/null || ' +
+ '/etc/init.d/cron restart 2>/dev/null || ' +
+ '/etc/init.d/crond reload 2>/dev/null || ' +
+ '/etc/init.d/crond restart 2>/dev/null || ' +
+ 'killall -HUP crond 2>/dev/null || true'
+ ]
+ });
+ }
+
async loadSSHKeys() {
await this.core.loadResource('ssh-keys-table', 4, 'ssh_keys', async () => {
try {
- const [s, r] = await this.core.ubusCall('file', 'read', { path: '/etc/dropbear/authorized_keys' });
- this.sshKeysRaw = s === 0 && r?.data ? r.data : '';
+ const [s, r] = await this.core.ubusCall('file', 'read', {
+ path: '/etc/dropbear/authorized_keys'
+ });
+ if (s === 0 && r?.data) this.sshKeysRaw = r.data;
+ else this.sshKeysRaw = '';
} catch {
this.sshKeysRaw = '';
}
const keys = this.parseSSHKeys(this.sshKeysRaw);
- this.core.renderTable(
- '#ssh-keys-table',
- keys,
- 4,
- 'No SSH keys',
- (k, i) => `
+ const tbody = document.querySelector('#ssh-keys-table tbody');
+ if (!tbody) return;
+ if (keys.length === 0) {
+ this.core.renderEmptyTable(tbody, 4, 'No SSH keys');
+ return;
+ }
+ tbody.innerHTML = keys
+ .map(
+ (k, i) => `
${this.core.escapeHtml(k.type)}
${this.core.escapeHtml(k.key.substring(0, 30))}...
${this.core.escapeHtml(k.comment || 'N/A')}
@@ -478,13 +889,15 @@ export default class SystemModule {
`
- );
+ )
+ .join('');
});
}
parseSSHKeys(data) {
const result = [];
- data.split('\n').forEach((line, rawIndex) => {
+ const lines = data.split('\n');
+ lines.forEach((line, rawIndex) => {
if (!line.trim() || line.startsWith('#')) return;
const parts = line.trim().split(/\s+/);
if (!parts[1]) return;
@@ -514,8 +927,10 @@ export default class SystemModule {
list.innerHTML = keys
.map(
- (k, i) =>
- `
MOUNT POINTS
| ${this.core.escapeHtml(lease.ipaddr || 'Unknown')} | ${this.core.escapeHtml(lease.macaddr || 'Unknown')} | ${this.core.escapeHtml(lease.hostname || 'Unknown')} | Active | -||||
| ${this.core.escapeHtml(`${marker}${row.hostname}`)} | +${ipText} | +${this.core.escapeHtml(row.mac)} | +${this.core.escapeHtml(upload)} | +${this.core.escapeHtml(download)} | +${row.online ? 'ONLINE' : 'OFFLINE'} | +${pinBtn} | +|
| ${this.renderNetifyDetail(row.mac)} | +|||||||
| ${this.core.escapeHtml(item.name)} | +${this.core.escapeHtml(this.core.formatBytes(item.bytes || 0))} | +||||||
| No nlbw Layer7 data for this device | |||||||
| APPLICATION | +TOTAL BYTES | +
|---|
+ ${nlbwSection}
+
`;
+ }
+
+ const state = this.netifyByMac.get(mac);
+ if (!state || state.loading) {
+ return `Netify details are disabled (moci.features.netify=0).
+
+ ${nlbwSection}
+
`;
+ }
+ if (state.error) {
+ return `Loading additional data...
+
+ ${nlbwSection}
+
`;
+ }
+
+ const summary = state.summary || {
+ flows: 0,
+ apps: 0,
+ bytes: 0,
+ lastSeen: 'N/A',
+ topApps: [],
+ recent: []
+ };
+
+ const appRows =
+ summary.topApps.length > 0
+ ? summary.topApps
+ .map(
+ item => `Netify data unavailable: ${this.core.escapeHtml(state.error)}
+
+ ${nlbwSection}
+
+
+
+
`;
+ }
+
+ async loadNetifyDetails(mac) {
+ try {
+ const dbPath = await this.resolveNetifyDbPath();
+ const lines = await this.fetchDeviceFlowWindow(dbPath, mac, this.deviceMaxRows);
+
+ const flows = [];
+ for (const line of lines) {
+ try {
+ const parsed = JSON.parse(line);
+ if (parsed?.type === 'flow' && parsed?.flow) flows.push(parsed.flow);
+ } catch {}
+ }
+
+ const apps = new Map();
+ let bytes = 0;
+ let lastSeen = 0;
+ const recent = [];
+ for (const flow of flows) {
+ const app = flow.detected_application_name || flow.detected_app_name || flow.host_server_name || flow.dns_host_name || 'Unknown';
+ apps.set(app, (apps.get(app) || 0) + 1);
+ bytes += Number(flow.total_bytes || 0) || Number(flow.other_bytes || 0) || Number(flow.local_bytes || 0) || 0;
+ const tsRaw = Number(flow.last_seen_at || flow.first_seen_at || 0);
+ const ts = tsRaw > 1e12 ? tsRaw : tsRaw * 1000;
+ if (ts > lastSeen) lastSeen = ts;
+ if (recent.length < 6) {
+ recent.push({
+ time: this.formatTimestamp(ts || Date.now()),
+ fqdn: flow.host_server_name || flow.dns_host_name || flow.ssl?.client_sni || '',
+ app,
+ proto: flow.detected_protocol_name || 'N/A',
+ destIp: flow.other_ip || '-'
+ });
+ }
+ }
+
+ const topApps = Array.from(apps.entries())
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 10)
+ .map(([name, count]) => ({ name, count }));
+
+ this.netifyByMac.set(mac, {
+ loading: false,
+ summary: {
+ flows: flows.length,
+ apps: apps.size,
+ bytes,
+ lastSeen: lastSeen ? this.formatTimestamp(lastSeen) : 'N/A',
+ topApps,
+ recent
+ }
+ });
+ } catch (err) {
+ this.netifyByMac.set(mac, {
+ loading: false,
+ error: err?.message || 'query failed'
+ });
+ }
+ }
+
+ async fetchDeviceFlowWindow(dbPath, mac, requestedRows) {
+ const safeMac = this.normalizeMac(mac);
+ if (!safeMac) return [];
+
+ const maxWindowRows = Math.max(20, this.deviceSqlChunkSize * this.deviceSqlChunkCalls);
+ const requested = Math.max(20, Math.min(Number(requestedRows) || this.deviceMaxRows, maxWindowRows));
+ const tried = new Set();
+ const limits = [requested, 1500, 800, 400, 200].filter(n => {
+ if (n < 20 || tried.has(n)) return false;
+ tried.add(n);
+ return true;
+ });
+
+ let lastErr = null;
+ for (const limit of limits) {
+ try {
+ const rows = await this.fetchDeviceFlowChunkWindow(dbPath, safeMac, limit, 0);
+ if (rows.length > 0) return rows;
+ } catch (err) {
+ lastErr = err;
+ }
+ }
+ if (lastErr) throw lastErr;
+ return [];
+ }
+
+ async fetchDeviceFlowChunkWindow(dbPath, mac, limit, startOffset) {
+ const maxStep = Math.max(20, Number(this.deviceSqlChunkSize) || 200);
+ const maxCalls = Math.max(1, Number(this.deviceSqlChunkCalls) || 15);
+ let remaining = Math.max(0, Number(limit) || 0);
+ let offset = Math.max(0, Number(startOffset) || 0);
+ let combined = [];
+ let calls = 0;
+
+ while (remaining > 0 && calls < maxCalls) {
+ const step = Math.min(maxStep, remaining);
+ const sql = `SELECT json FROM flow_raw WHERE json LIKE '%"local_mac":"${mac}"%' ORDER BY id DESC LIMIT ${step} OFFSET ${offset};`;
+ const out = await this.querySql(dbPath, sql);
+ const lines = String(out || '')
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean);
+ if (lines.length === 0) break;
+
+ combined = combined.concat(lines);
+ offset += lines.length;
+ remaining -= lines.length;
+ calls += 1;
+ if (lines.length < step) break;
+ }
+
+ return combined;
+ }
+
+ async resolveNetifyDbPath() {
+ try {
+ const [status, result] = await this.core.uciGet('moci', 'collector');
+ if (status === 0 && result?.values?.db_path) {
+ this.netifyDbPath = String(result.values.db_path).trim() || this.netifyDbPath;
+ }
+ } catch {}
+ return this.netifyDbPath;
+ }
+
+ async querySql(dbPath, sql) {
+ const statement = `PRAGMA busy_timeout=3000; ${sql}`;
+ const db = this.shellQuote(dbPath);
+ const sqlQuoted = this.shellQuote(statement);
+ const shellCmd = `if command -v sqlite3 >/dev/null 2>&1; then sqlite3 ${db} ${sqlQuoted}; elif command -v sqlite3-cli >/dev/null 2>&1; then sqlite3-cli ${db} ${sqlQuoted}; else echo "sqlite3 not installed" >&2; exit 127; fi`;
+ const result = await this.exec('/bin/sh', ['-c', shellCmd], { timeout: 12000 });
+ return String(result?.stdout || '');
+ }
+
+ formatTimestamp(ts) {
+ const d = new Date(ts);
+ return d.toLocaleString([], {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ });
+ }
+
+ async exec(command, params = [], options = {}) {
+ const [status, result] = await this.core.ubusCall('file', 'exec', { command, params }, options);
+ if (status !== 0) throw new Error(`${command} failed (${status})`);
+ return result || {};
+ }
+
+ shellQuote(value) {
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
+ }
+
+ openPinDialog(mac) {
+ const normalizedMac = this.normalizeMac(mac);
+ if (!normalizedMac) {
+ this.core.showToast('Device MAC not available', 'error');
+ return;
+ }
+
+ const row = this.rowsByMac.get(normalizedMac);
+ if (!row) {
+ this.core.showToast('Device not found', 'error');
+ return;
+ }
+
+ document.getElementById('devices-pin-section').value = row.staticSection || '';
+ document.getElementById('devices-pin-hostname').value = row.hostname && row.hostname !== 'Unknown' ? row.hostname : '';
+ document.getElementById('devices-pin-mac').value = normalizedMac;
+ const staticCheckbox = document.getElementById('devices-pin-static');
+ if (staticCheckbox) staticCheckbox.checked = Boolean(row.pinned);
+ document.getElementById('devices-pin-ip').value = row.ip && row.ip !== 'N/A' ? row.ip : '';
+ this.syncStaticIpField();
+ this.core.openModal('devices-pin-modal');
+ }
+
+ syncStaticIpField() {
+ const staticCheckbox = document.getElementById('devices-pin-static');
+ const ipInput = document.getElementById('devices-pin-ip');
+ if (!staticCheckbox || !ipInput) return;
+ const useStatic = Boolean(staticCheckbox.checked);
+ ipInput.disabled = !useStatic;
+ ipInput.placeholder = useStatic ? '192.168.1.50' : 'Disabled unless Static IP is ON';
+ }
+
+ isValidIpv4(ip) {
+ const parts = String(ip || '').trim().split('.');
+ if (parts.length !== 4) return false;
+ return parts.every(part => {
+ if (!/^\d+$/.test(part)) return false;
+ const n = Number(part);
+ return n >= 0 && n <= 255;
+ });
+ }
+
+ async savePinnedIp() {
+ const section = document.getElementById('devices-pin-section').value;
+ const hostname = (document.getElementById('devices-pin-hostname').value || '').trim();
+ const mac = this.normalizeMac(document.getElementById('devices-pin-mac').value);
+ const ip = (document.getElementById('devices-pin-ip').value || '').trim();
+ const useStatic = Boolean(document.getElementById('devices-pin-static')?.checked);
+
+ if (!mac) {
+ this.core.showToast('Invalid MAC address', 'error');
+ return;
+ }
+ if (useStatic && !this.isValidIpv4(ip)) {
+ this.core.showToast('Enter a valid IPv4 address', 'error');
+ return;
+ }
+ if (!useStatic && !hostname && !section) {
+ this.core.showToast('Set a hostname or enable Static IP', 'error');
+ return;
+ }
+
+ try {
+ const values = {
+ name: hostname,
+ mac
+ };
+ if (useStatic) values.ip = ip;
+ if (section) {
+ await this.core.uciSet('dhcp', section, values);
+ if (!useStatic) {
+ await this.core.uciDelete('dhcp', section, 'ip').catch(() => {});
+ }
+ } else {
+ const [, addResult] = await this.core.uciAdd('dhcp', 'host');
+ const targetSection = addResult?.section;
+ if (!targetSection) throw new Error('Failed to create DHCP host section');
+ await this.core.uciSet('dhcp', targetSection, values);
+ }
+ await this.core.uciCommit('dhcp');
+
+ this.core.closeModal('devices-pin-modal');
+ this.core.showToast(useStatic ? 'Static lease saved for device' : 'Device name saved', 'success');
+ await this.loadDevices();
+ } catch (err) {
+ console.error('Failed to save static lease:', err);
+ this.core.showToast('Failed to save static IP', 'error');
+ }
+ }
+
+ renderSourceStatus() {
+ const el = document.getElementById('devices-source-status');
+ if (!el) return;
+ const nlbwText = this.nlbwAvailable ? 'NLBWMON: READY' : 'NLBWMON: UNAVAILABLE';
+ const netifyText = this.netifyFeatureEnabled ? 'NETIFY DETAILS: ENABLED' : 'NETIFY DETAILS: DISABLED';
+ el.textContent = `${nlbwText} | ${netifyText}`;
+ }
+}
diff --git a/moci/js/modules/monitoring.js b/moci/js/modules/monitoring.js
new file mode 100644
index 0000000..5eb1496
--- /dev/null
+++ b/moci/js/modules/monitoring.js
@@ -0,0 +1,913 @@
+export default class MonitoringModule {
+ constructor(core) {
+ this.core = core;
+ this.initialized = false;
+ this.subTabs = null;
+ this.refreshTimer = null;
+ this.serviceRunning = false;
+ this.target = '1.1.1.1';
+ this.intervalSec = 60;
+ this.thresholdMs = 100;
+ this.outputFile = '/tmp/moci-ping-monitor.txt';
+ this.samples = [];
+ this.pingSection = 'ping_monitor';
+
+ this.speedtestSection = 'speedtest_monitor';
+ this.speedtestEnabled = true;
+ this.speedtestHour = 3;
+ this.speedtestMinute = 15;
+ this.speedtestOutputFile = '/tmp/moci-speedtest-monitor.txt';
+ this.speedtestMaxLines = 365;
+ this.speedtestSamples = [];
+
+ this.core.registerRoute('/monitoring', async (path, subPaths) => {
+ const pageElement = document.getElementById('monitoring-page');
+ if (pageElement) pageElement.classList.remove('hidden');
+
+ if (!this.initialized) {
+ this.setupHandlers();
+ this.initialized = true;
+ }
+
+ if (!this.subTabs) {
+ this.subTabs = this.core.setupSubTabs('monitoring-page', {
+ ping: () => this.loadMonitoring(),
+ speedtest: () => this.loadMonitoring()
+ });
+ this.subTabs.attachListeners();
+ }
+
+ const tab = subPaths?.[0] || 'ping';
+ this.subTabs.showSubTab(tab);
+ });
+ }
+
+ async loadMonitoring() {
+ await this.loadConfig();
+ await this.refresh();
+ this.startRefreshLoop();
+ }
+
+ setupHandlers() {
+ const targetInput = document.getElementById('monitoring-target');
+ const intervalInput = document.getElementById('monitoring-interval');
+ const thresholdInput = document.getElementById('monitoring-threshold');
+ const speedtestTimeInput = document.getElementById('monitoring-speedtest-time');
+ if (targetInput) targetInput.value = this.target;
+ if (intervalInput) intervalInput.value = String(this.intervalSec);
+ if (thresholdInput) thresholdInput.value = String(this.thresholdMs);
+ if (speedtestTimeInput) speedtestTimeInput.value = this.formatTimeValue(this.speedtestHour, this.speedtestMinute);
+
+ document.getElementById('monitoring-apply-btn')?.addEventListener('click', () => this.applySettings());
+ document.getElementById('monitoring-toggle-btn')?.addEventListener('click', () => this.toggleService());
+ document.getElementById('monitoring-run-now-btn')?.addEventListener('click', () => this.runOnce());
+ document.getElementById('monitoring-clear-btn')?.addEventListener('click', () => this.clearHistory());
+ document.getElementById('monitoring-speedtest-enable-btn')?.addEventListener('click', () => this.applySpeedtestSettings(true));
+ document.getElementById('monitoring-speedtest-disable-btn')?.addEventListener('click', () => this.applySpeedtestSettings(false));
+ document.getElementById('monitoring-speedtest-run-now-btn')?.addEventListener('click', () => this.runSpeedtestNow());
+ document.getElementById('monitoring-speedtest-clear-btn')?.addEventListener('click', () => this.clearSpeedtestHistory());
+ document
+ .getElementById('monitoring-settings-toggle-btn')
+ ?.addEventListener('click', () => this.toggleSettingsPanel());
+ document
+ .getElementById('monitoring-speedtest-settings-toggle-btn')
+ ?.addEventListener('click', () => this.toggleSpeedtestSettingsPanel());
+ document.getElementById('monitoring-speedtest-time')?.addEventListener('change', () => {
+ if (this.speedtestEnabled) this.applySpeedtestSettings(true);
+ });
+ this.updateSpeedtestToggleButtons();
+ this.syncSettingsPanel();
+ this.syncSpeedtestSettingsPanel();
+ }
+
+ toggleSettingsPanel() {
+ const body = document.getElementById('monitoring-settings-body');
+ const icon = document.getElementById('monitoring-settings-toggle-icon');
+ const btn = document.getElementById('monitoring-settings-toggle-btn');
+ if (!body || !icon || !btn) return;
+
+ const isHidden = body.style.display === 'none' || body.style.display === '';
+ if (isHidden) {
+ body.style.display = 'block';
+ icon.textContent = '▾';
+ btn.setAttribute('aria-expanded', 'true');
+ localStorage.setItem('monitoring_settings_expanded', '1');
+ } else {
+ body.style.display = 'none';
+ icon.textContent = '▸';
+ btn.setAttribute('aria-expanded', 'false');
+ localStorage.setItem('monitoring_settings_expanded', '0');
+ }
+ }
+
+ syncSettingsPanel() {
+ const body = document.getElementById('monitoring-settings-body');
+ const icon = document.getElementById('monitoring-settings-toggle-icon');
+ const btn = document.getElementById('monitoring-settings-toggle-btn');
+ if (!body || !icon || !btn) return;
+
+ const expanded = localStorage.getItem('monitoring_settings_expanded') === '1';
+ if (expanded) {
+ body.style.display = 'block';
+ icon.textContent = '▾';
+ btn.setAttribute('aria-expanded', 'true');
+ } else {
+ body.style.display = 'none';
+ icon.textContent = '▸';
+ btn.setAttribute('aria-expanded', 'false');
+ }
+ }
+
+ toggleSpeedtestSettingsPanel() {
+ const body = document.getElementById('monitoring-speedtest-settings-body');
+ const icon = document.getElementById('monitoring-speedtest-settings-toggle-icon');
+ const btn = document.getElementById('monitoring-speedtest-settings-toggle-btn');
+ if (!body || !icon || !btn) return;
+
+ const isHidden = body.style.display === 'none' || body.style.display === '';
+ if (isHidden) {
+ body.style.display = 'block';
+ icon.textContent = '▾';
+ btn.setAttribute('aria-expanded', 'true');
+ localStorage.setItem('monitoring_speedtest_settings_expanded', '1');
+ } else {
+ body.style.display = 'none';
+ icon.textContent = '▸';
+ btn.setAttribute('aria-expanded', 'false');
+ localStorage.setItem('monitoring_speedtest_settings_expanded', '0');
+ }
+ }
+
+ syncSpeedtestSettingsPanel() {
+ const body = document.getElementById('monitoring-speedtest-settings-body');
+ const icon = document.getElementById('monitoring-speedtest-settings-toggle-icon');
+ const btn = document.getElementById('monitoring-speedtest-settings-toggle-btn');
+ if (!body || !icon || !btn) return;
+
+ const expanded = localStorage.getItem('monitoring_speedtest_settings_expanded') === '1';
+ if (expanded) {
+ body.style.display = 'block';
+ icon.textContent = '▾';
+ btn.setAttribute('aria-expanded', 'true');
+ } else {
+ body.style.display = 'none';
+ icon.textContent = '▸';
+ btn.setAttribute('aria-expanded', 'false');
+ }
+ }
+
+ async loadConfig() {
+ try {
+ const section = await this.resolvePingSection();
+ const [status, result] = await this.core.uciGet('moci', section);
+ if (status === 0 && result?.values) {
+ const c = result.values;
+ this.pingSection = section;
+ this.target = c.target || this.target;
+ this.intervalSec = Number(c.interval) || this.intervalSec;
+ this.thresholdMs = Number(c.threshold) || this.thresholdMs;
+ this.outputFile = c.output_file || this.outputFile;
+ }
+ } catch {}
+
+ try {
+ const section = await this.resolveSpeedtestSection();
+ const [status, result] = await this.core.uciGet('moci', section);
+ if (status === 0 && result?.values) {
+ const c = result.values;
+ this.speedtestSection = section;
+ this.speedtestEnabled = String(c.enabled ?? '1') !== '0';
+ this.speedtestHour = this.clampInt(c.run_hour, 3, 0, 23);
+ this.speedtestMinute = this.clampInt(c.run_minute, 15, 0, 59);
+ this.speedtestOutputFile = c.output_file || this.speedtestOutputFile;
+ this.speedtestMaxLines = this.clampInt(c.max_lines, this.speedtestMaxLines, 10, 9999999);
+ }
+ } catch {}
+
+ const targetInput = document.getElementById('monitoring-target');
+ const intervalInput = document.getElementById('monitoring-interval');
+ const thresholdInput = document.getElementById('monitoring-threshold');
+ const speedtestTimeInput = document.getElementById('monitoring-speedtest-time');
+ if (targetInput) targetInput.value = this.target;
+ if (intervalInput) intervalInput.value = String(this.intervalSec);
+ if (thresholdInput) thresholdInput.value = String(this.thresholdMs);
+ if (speedtestTimeInput) speedtestTimeInput.value = this.formatTimeValue(this.speedtestHour, this.speedtestMinute);
+ this.updateSpeedtestToggleButtons();
+ }
+
+ async applySettings() {
+ const targetInput = document.getElementById('monitoring-target');
+ const intervalInput = document.getElementById('monitoring-interval');
+ const thresholdInput = document.getElementById('monitoring-threshold');
+
+ const target = (targetInput?.value || '').trim() || '1.1.1.1';
+ const interval = Number(intervalInput?.value || 60);
+ const threshold = Number(thresholdInput?.value || this.thresholdMs || 100);
+
+ if (!/^[a-zA-Z0-9.\-:]+$/.test(target)) {
+ this.core.showToast('Invalid target host/IP', 'error');
+ return;
+ }
+ if (!Number.isFinite(interval) || interval < 5 || interval > 3600) {
+ this.core.showToast('Interval must be between 5 and 3600 seconds', 'error');
+ return;
+ }
+ if (!Number.isFinite(threshold) || threshold < 1 || threshold > 10000) {
+ this.core.showToast('Threshold must be between 1 and 10000 ms', 'error');
+ return;
+ }
+
+ try {
+ const section = await this.resolvePingSection(true);
+ await this.core.uciSet('moci', section, {
+ target,
+ interval: String(interval),
+ threshold: String(Math.round(threshold))
+ });
+ await this.core.uciCommit('moci');
+ this.pingSection = section;
+ this.target = target;
+ this.intervalSec = interval;
+ this.thresholdMs = Math.round(threshold);
+ let restartFailed = false;
+ try {
+ await this.exec('/etc/init.d/ping-monitor', ['restart']);
+ } catch (err) {
+ restartFailed = true;
+ console.warn('Ping monitor restart failed after settings commit:', err);
+ }
+ await this.refresh();
+ this.core.showToast(
+ restartFailed
+ ? 'Settings saved. Service restart was blocked; new settings apply on next monitor cycle.'
+ : 'Ping monitor settings applied',
+ restartFailed ? 'warning' : 'success'
+ );
+ } catch (err) {
+ console.error('Failed to apply ping monitor settings:', err);
+ this.core.showToast(`Failed to apply settings: ${err?.message || 'unknown error'}`, 'error');
+ }
+ }
+
+ async applySpeedtestSettings(forceEnabled = null) {
+ const timeInput = document.getElementById('monitoring-speedtest-time');
+ const enabled = forceEnabled == null ? this.speedtestEnabled : Boolean(forceEnabled);
+ const rawTime = String(timeInput?.value || '').trim() || '03:15';
+ const parsed = this.parseTimeValue(rawTime);
+ if (!parsed) {
+ this.core.showToast('Invalid daily run time', 'error');
+ return;
+ }
+
+ const [hour, minute] = parsed;
+
+ try {
+ const section = await this.resolveSpeedtestSection(true);
+ await this.core.uciSet('moci', section, {
+ enabled: enabled ? '1' : '0',
+ run_hour: String(hour),
+ run_minute: String(minute),
+ output_file: this.speedtestOutputFile,
+ max_lines: String(this.speedtestMaxLines)
+ });
+ await this.core.uciCommit('moci');
+ this.speedtestSection = section;
+ this.speedtestEnabled = enabled;
+ this.speedtestHour = hour;
+ this.speedtestMinute = minute;
+
+ await this.syncSpeedtestCron();
+ await this.refresh();
+ this.updateSpeedtestToggleButtons();
+ this.core.showToast('Daily speedtest schedule saved', 'success');
+ } catch (err) {
+ console.error('Failed to apply speedtest settings:', err);
+ this.core.showToast(`Failed to apply speedtest schedule: ${err?.message || 'unknown error'}`, 'error');
+ }
+ }
+
+ updateSpeedtestToggleButtons() {
+ const enableBtn = document.getElementById('monitoring-speedtest-enable-btn');
+ const disableBtn = document.getElementById('monitoring-speedtest-disable-btn');
+ if (!enableBtn || !disableBtn) return;
+ enableBtn.disabled = this.speedtestEnabled;
+ disableBtn.disabled = !this.speedtestEnabled;
+ }
+
+ async syncSpeedtestCron() {
+ const marker = '# MOCI_SPEEDTEST_MONITOR';
+ const cronPath = '/etc/crontabs/root';
+ let current = '';
+ try {
+ const [status, result] = await this.core.ubusCall('file', 'read', { path: cronPath });
+ if (status === 0 && result?.data) current = String(result.data);
+ } catch {}
+
+ const lines = current
+ .split('\n')
+ .map(line => line.trimEnd())
+ .filter(line => line && !line.includes(marker));
+
+ if (this.speedtestEnabled) {
+ const min = this.clampInt(this.speedtestMinute, 15, 0, 59);
+ const hour = this.clampInt(this.speedtestHour, 3, 0, 23);
+ lines.push(`${min} ${hour} * * * /usr/bin/moci-speedtest-monitor --once >/tmp/moci-speedtest-monitor.last.log 2>&1 ${marker}`);
+ }
+
+ await this.core.ubusCall('file', 'write', {
+ path: cronPath,
+ data: `${lines.join('\n')}\n`
+ });
+
+ await this.exec('/bin/sh', [
+ '-c',
+ '/etc/init.d/cron reload 2>/dev/null || /etc/init.d/cron restart 2>/dev/null || /etc/init.d/crond reload 2>/dev/null || /etc/init.d/crond restart 2>/dev/null || killall -HUP crond 2>/dev/null || true'
+ ]);
+ }
+
+ async resolvePingSection(createIfMissing = false) {
+ if (this.pingSection) {
+ try {
+ const [status, result] = await this.core.uciGet('moci', this.pingSection);
+ if (status === 0 && result?.values) return this.pingSection;
+ } catch {}
+ }
+
+ try {
+ const [status, result] = await this.core.uciGet('moci');
+ if (status === 0 && result?.values) {
+ for (const [section, values] of Object.entries(result.values)) {
+ if (values?.['.type'] === 'ping') {
+ this.pingSection = section;
+ return section;
+ }
+ }
+ }
+ } catch {}
+
+ if (createIfMissing) {
+ const [, addResult] = await this.core.uciAdd('moci', 'ping', 'ping_monitor');
+ const section = addResult?.section || 'ping_monitor';
+ this.pingSection = section;
+ return section;
+ }
+
+ return 'ping_monitor';
+ }
+
+ async resolveSpeedtestSection(createIfMissing = false) {
+ if (this.speedtestSection) {
+ try {
+ const [status, result] = await this.core.uciGet('moci', this.speedtestSection);
+ if (status === 0 && result?.values) return this.speedtestSection;
+ } catch {}
+ }
+
+ try {
+ const [status, result] = await this.core.uciGet('moci');
+ if (status === 0 && result?.values) {
+ for (const [section, values] of Object.entries(result.values)) {
+ if (values?.['.type'] === 'speedtest') {
+ this.speedtestSection = section;
+ return section;
+ }
+ }
+ }
+ } catch {}
+
+ if (createIfMissing) {
+ const [, addResult] = await this.core.uciAdd('moci', 'speedtest', 'speedtest_monitor');
+ const section = addResult?.section || 'speedtest_monitor';
+ this.speedtestSection = section;
+ return section;
+ }
+
+ return 'speedtest_monitor';
+ }
+
+ startRefreshLoop() {
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
+ this.refreshTimer = setInterval(() => {
+ if (this.core.currentRoute?.startsWith('/monitoring')) {
+ this.refresh();
+ }
+ }, 5000);
+ }
+
+ async refresh() {
+ try {
+ await this.updateServiceStatus();
+ await this.readPingFile();
+ await this.readSpeedtestFile();
+ this.renderAll();
+ } catch (err) {
+ console.error('Monitoring refresh failed:', err);
+ }
+ }
+
+ async updateServiceStatus() {
+ try {
+ const result = await this.exec('/bin/sh', ['-c', 'pgrep -f moci-ping-monitor >/dev/null && echo RUNNING || echo STOPPED']);
+ this.serviceRunning = (result.stdout || '').trim() === 'RUNNING';
+ } catch {
+ this.serviceRunning = false;
+ }
+ }
+
+ async readPingFile() {
+ try {
+ const [status, result] = await this.core.ubusCall('file', 'read', { path: this.outputFile });
+ if (status !== 0 || !result?.data) {
+ this.samples = [];
+ return;
+ }
+ this.samples = this.parseSamples(result.data);
+ } catch {
+ this.samples = [];
+ }
+ }
+
+ async readSpeedtestFile() {
+ try {
+ const [status, result] = await this.core.ubusCall('file', 'read', { path: this.speedtestOutputFile });
+ if (status !== 0 || !result?.data) {
+ this.speedtestSamples = [];
+ return;
+ }
+ this.speedtestSamples = this.parseSpeedtestSamples(result.data);
+ } catch {
+ this.speedtestSamples = [];
+ }
+ }
+
+ parseSamples(raw) {
+ return raw
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean)
+ .map(line => {
+ const [ts, target, status, latency, message] = line.split('|');
+ const parsedTs = Date.parse(ts);
+ return {
+ ts: Number.isNaN(parsedTs) ? Date.now() : parsedTs,
+ target: target || this.target,
+ status: status === 'OK' ? this.getStatusFromLatency(latency) : 'error',
+ latency: latency && latency !== 'N/A' ? parseFloat(latency) : null,
+ message: message || ''
+ };
+ })
+ .slice(-2000);
+ }
+
+ parseSpeedtestSamples(raw) {
+ return String(raw || '')
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean)
+ .map(line => {
+ const parts = line.split('|');
+ const ts = parts[0] || '';
+ const status = parts[1] || 'ERROR';
+ const download = parts[2] && parts[2] !== 'N/A' ? Number(parts[2]) : null;
+ const upload = parts[3] && parts[3] !== 'N/A' ? Number(parts[3]) : null;
+ const server = parts[4] || '';
+ const message = parts.slice(5).join('|') || '';
+ const parsedTs = Date.parse(ts);
+ return {
+ ts: Number.isNaN(parsedTs) ? Date.now() : parsedTs,
+ status,
+ download: Number.isFinite(download) ? download : null,
+ upload: Number.isFinite(upload) ? upload : null,
+ server,
+ message
+ };
+ })
+ .slice(-2000);
+ }
+
+ getStatusFromLatency(latency) {
+ const value = parseFloat(latency);
+ if (Number.isNaN(value)) return 'error';
+ if (value >= this.thresholdMs) return 'critical';
+ if (value >= Math.max(1, this.thresholdMs * 0.7)) return 'warn';
+ if (value >= 75) return 'good';
+ return 'ok';
+ }
+
+ isColorfulGraphsEnabled() {
+ return this.core.isFeatureEnabled('colorful_graphs');
+ }
+
+ getSpeedtestPalette() {
+ if (this.isColorfulGraphsEnabled()) {
+ return {
+ download: 'rgba(56, 189, 248, 0.95)',
+ upload: 'rgba(248, 153, 56, 0.95)'
+ };
+ }
+ return {
+ download: 'rgba(226, 226, 229, 0.92)',
+ upload: 'rgba(180, 180, 185, 0.88)'
+ };
+ }
+
+ async toggleService() {
+ const action = this.serviceRunning ? 'stop' : 'start';
+ try {
+ await this.exec('/etc/init.d/ping-monitor', [action]);
+ await this.refresh();
+ this.core.showToast(`Ping service ${action}ed`, 'success');
+ } catch (err) {
+ console.error(`Failed to ${action} ping service:`, err);
+ this.core.showToast(`Failed to ${action} service`, 'error');
+ }
+ }
+
+ async runOnce() {
+ try {
+ await this.exec('/usr/bin/moci-ping-monitor', ['--once'], { timeout: 12000 });
+ await this.refresh();
+ this.core.showToast('One ping sample captured', 'success');
+ } catch (err) {
+ console.error('Failed to run ping once:', err);
+ this.core.showToast('Failed to run ping once', 'error');
+ }
+ }
+
+ async clearHistory() {
+ try {
+ await this.core.ubusCall('file', 'write', { path: this.outputFile, data: '' });
+ await this.refresh();
+ this.core.showToast('Ping history cleared', 'success');
+ } catch (err) {
+ console.error('Failed to clear ping history:', err);
+ this.core.showToast('Failed to clear history', 'error');
+ }
+ }
+
+ async runSpeedtestNow() {
+ try {
+ await this.exec('/usr/bin/moci-speedtest-monitor', ['--once'], { timeout: 240000 });
+ await this.refresh();
+ this.core.showToast('Speedtest captured', 'success');
+ } catch (err) {
+ console.error('Failed to run speedtest now:', err);
+ this.core.showToast('Failed to run speedtest', 'error');
+ }
+ }
+
+ async clearSpeedtestHistory() {
+ try {
+ await this.core.ubusCall('file', 'write', { path: this.speedtestOutputFile, data: '' });
+ await this.refresh();
+ this.core.showToast('Speedtest history cleared', 'success');
+ } catch (err) {
+ console.error('Failed to clear speedtest history:', err);
+ this.core.showToast('Failed to clear speedtest history', 'error');
+ }
+ }
+
+ renderAll() {
+ const aggregated = this.aggregateFiveMinuteSamples(this.samples);
+ this.renderStatusCard(aggregated);
+ this.renderTimeline(aggregated);
+ this.renderRecentTable(aggregated);
+ this.renderSpeedtestPanel();
+ }
+
+ aggregateFiveMinuteSamples(samples) {
+ const bucketMs = 5 * 60 * 1000;
+ const buckets = new Map();
+
+ for (const sample of samples || []) {
+ const ts = Number(sample?.ts) || Date.now();
+ const bucketStart = Math.floor(ts / bucketMs) * bucketMs;
+ let b = buckets.get(bucketStart);
+ if (!b) {
+ b = {
+ start: bucketStart,
+ lastTs: ts,
+ target: sample?.target || this.target,
+ total: 0,
+ valid: 0,
+ latencySum: 0
+ };
+ buckets.set(bucketStart, b);
+ }
+
+ b.total += 1;
+ if (sample?.latency != null && Number.isFinite(Number(sample.latency))) {
+ b.valid += 1;
+ b.latencySum += Number(sample.latency);
+ }
+ if (ts > b.lastTs) {
+ b.lastTs = ts;
+ b.target = sample?.target || b.target;
+ }
+ }
+
+ return Array.from(buckets.values())
+ .sort((a, b) => a.start - b.start)
+ .map(b => {
+ const latency = b.valid > 0 ? b.latencySum / b.valid : null;
+ const status = latency == null ? 'error' : this.getStatusFromLatency(latency);
+ return {
+ ts: b.lastTs,
+ target: b.target || this.target,
+ latency,
+ status,
+ totalPings: b.total,
+ validPings: b.valid,
+ lossPct: b.total > 0 ? ((b.total - b.valid) / b.total) * 100 : 0
+ };
+ });
+ }
+
+ renderStatusCard(displaySamples = []) {
+ const toggleBtn = document.getElementById('monitoring-toggle-btn');
+ if (toggleBtn) toggleBtn.textContent = this.serviceRunning ? 'STOP SERVICE' : 'START SERVICE';
+
+ const latest = displaySamples[displaySamples.length - 1];
+ const latencyEl = document.getElementById('monitoring-latency');
+ const statusEl = document.getElementById('monitoring-status');
+ const avgEl = document.getElementById('monitoring-avg');
+ const lossEl = document.getElementById('monitoring-loss');
+
+ if (latencyEl) {
+ latencyEl.textContent = latest?.latency != null ? `${latest.latency.toFixed(1)} ms` : 'N/A';
+ }
+
+ if (statusEl) {
+ const statusBadge = this.getStatusBadge(latest?.status || 'error');
+ statusEl.innerHTML = statusBadge;
+ }
+
+ const windowSamples = displaySamples.slice(-12);
+ const valid = windowSamples.filter(s => s.latency != null);
+ const avg = valid.length > 0 ? valid.reduce((sum, s) => sum + s.latency, 0) / valid.length : 0;
+ const totalPings = windowSamples.reduce((sum, s) => sum + (Number(s.totalPings) || 0), 0);
+ const totalValid = windowSamples.reduce((sum, s) => sum + (Number(s.validPings) || 0), 0);
+ const loss = totalPings > 0 ? ((totalPings - totalValid) / totalPings) * 100 : 0;
+
+ if (avgEl) avgEl.textContent = `${avg.toFixed(1)} ms`;
+ if (lossEl) lossEl.textContent = `${loss.toFixed(0)}%`;
+ }
+
+ getStatusBadge(status) {
+ if (status === 'ok') {
+ const badge = this.core.renderBadge('success', 'excellent');
+ return this.isColorfulGraphsEnabled()
+ ? badge.replace('class="badge badge-success"', 'class="badge badge-success monitoring-excellent-soft"')
+ : badge;
+ }
+ if (status === 'good') return this.core.renderBadge('info', 'good');
+ if (status === 'critical') {
+ const badge = this.core.renderBadge('error', `over ${Math.round(this.thresholdMs)}ms`);
+ return this.isColorfulGraphsEnabled()
+ ? badge.replace('class="badge badge-error"', 'class="badge badge-error monitoring-high-latency-soft"')
+ : badge;
+ }
+ if (status === 'warn') {
+ const badge = this.core.renderBadge('warning', 'high latency');
+ return this.isColorfulGraphsEnabled()
+ ? badge.replace('class="badge badge-warning"', 'class="badge badge-warning monitoring-latency-soft"')
+ : badge;
+ }
+ const badge = this.core.renderBadge('error', 'outage');
+ return this.isColorfulGraphsEnabled()
+ ? badge.replace('class="badge badge-error"', 'class="badge badge-error monitoring-high-latency-soft"')
+ : badge;
+ }
+
+ renderTimeline(displaySamples = []) {
+ const bars = document.getElementById('monitoring-timeline-bars');
+ const labels = document.getElementById('monitoring-timeline-labels');
+ if (!bars || !labels) return;
+
+ const segments = displaySamples.slice(-12);
+ if (segments.length === 0) {
+ bars.innerHTML = '
+ FLOWS: ${this.core.escapeHtml(String(summary.flows))}
+ APPLICATIONS: ${this.core.escapeHtml(String(summary.apps))}
+ TOTAL BYTES: ${this.core.escapeHtml(this.core.formatBytes(summary.bytes || 0))}
+ LAST SEEN: ${this.core.escapeHtml(summary.lastSeen)}
+
+ TOP APPLICATIONS (10)
+ | APPLICATION | +FLOWS | +
|---|
No data yet
';
+ labels.innerHTML = '';
+ return;
+ }
+
+ bars.innerHTML = segments
+ .map(segment => {
+ const cls = this.getSegmentClass(segment);
+ const latency = segment.latency != null ? `${segment.latency.toFixed(1)}ms` : 'timeout';
+ const title = `${this.formatTime(segment.ts)} • avg ${latency} (${segment.validPings || 0}/${segment.totalPings || 0} pings)`;
+ return ``;
+ })
+ .join('');
+
+ labels.innerHTML = segments.map(segment => `${this.formatTime(segment.ts, true)}`).join('');
+ }
+
+ getSegmentClass(segment) {
+ if (segment.status === 'error') return 'seg-error';
+ if (segment.status === 'critical') return 'seg-error';
+ if (segment.status === 'warn') return 'seg-warn';
+ if (segment.status === 'good') return 'seg-good';
+ return 'seg-ok';
+ }
+
+ renderRecentTable(displaySamples = []) {
+ const tbody = document.querySelector('#monitoring-recent-table tbody');
+ if (!tbody) return;
+
+ const rows = displaySamples.slice(-12).reverse();
+ if (rows.length === 0) {
+ this.core.renderEmptyTable(tbody, 4, 'No ping samples yet');
+ return;
+ }
+
+ tbody.innerHTML = rows
+ .map(row => {
+ const latency = row.latency != null ? `${row.latency.toFixed(1)} ms avg` : 'timeout';
+ return `
+
+
+
Running...
';
const commands = {
- ping: { command: '/bin/ping', params: ['-c', '5', '-W', '3', host] },
- traceroute: { command: '/usr/bin/traceroute', params: ['-w', '3', '-m', '15', host] }
+ ping: { command: '/bin/ping', params: ['-c', '5', '-W', '3', host], timeout: 30000 },
+ traceroute: {
+ command: '/bin/sh',
+ params: [
+ '-c',
+ 'if command -v traceroute >/dev/null 2>&1; then traceroute -w 2 -m 15 "$1"; ' +
+ 'elif command -v traceroute-nanog >/dev/null 2>&1; then traceroute-nanog -w 2 -m 15 "$1"; ' +
+ 'elif command -v busybox >/dev/null 2>&1; then busybox traceroute -w 2 -m 15 "$1"; ' +
+ 'else echo "traceroute command not found"; exit 127; fi',
+ 'sh',
+ host
+ ],
+ timeout: 45000
+ },
+ nslookup: {
+ command: '/bin/sh',
+ params: [
+ '-c',
+ 'if command -v nslookup >/dev/null 2>&1; then nslookup "$1"; ' +
+ 'elif command -v busybox >/dev/null 2>&1; then busybox nslookup "$1"; ' +
+ 'else echo "nslookup command not found"; exit 127; fi',
+ 'sh',
+ host
+ ],
+ timeout: 30000
+ }
};
try {
- const [s, r] = await this.core.ubusCall('file', 'exec', commands[type], { timeout: 30000 });
+ const cmd = commands[type];
+ const [s, r] = await this.core.ubusCall('file', 'exec', { command: cmd.command, params: cmd.params }, {
+ timeout: cmd.timeout || 30000
+ });
if (s !== 0) throw new Error('Command failed');
const text = (r.stdout || '') + (r.stderr || '');
output.innerHTML = text
diff --git a/moci/js/modules/services.js b/moci/js/modules/services.js
new file mode 100644
index 0000000..4c8b4ab
--- /dev/null
+++ b/moci/js/modules/services.js
@@ -0,0 +1,140 @@
+export default class ServicesModule {
+ constructor(core) {
+ this.core = core;
+ }
+
+ async fetchQoSConfig() {
+ const [status, result] = await this.core.uciGet('qos');
+
+ if (status !== 0 || !result?.values) {
+ throw new Error('QoS not configured');
+ }
+
+ return result.values;
+ }
+
+ parseQoSRules(config) {
+ return Object.entries(config)
+ .filter(([key, val]) => val['.type'] === 'classify')
+ .map(([key, val]) => ({
+ name: key,
+ ...val
+ }));
+ }
+
+ renderQoSRow(rule) {
+ const enabled = rule.enabled !== '0';
+ const statusBadge = enabled
+ ? this.core.renderBadge('success', 'ACTIVE')
+ : this.core.renderBadge('error', 'INACTIVE');
+
+ return `
+ Failed to load MoCI config.
';
+ });
}
cleanup() {
@@ -101,6 +135,7 @@ export default class SystemModule {
}
async loadGeneral() {
+ await this.loadMociConfig();
if (!this.core.isFeatureEnabled('system')) return;
try {
const [status, boardInfo] = await this.core.ubusCall('system', 'board', {});
@@ -114,6 +149,81 @@ export default class SystemModule {
} catch {}
}
+ getMociFeatureKeys() {
+ const defaults = this.core.getDefaultFeatures ? this.core.getDefaultFeatures() : {};
+ return Object.keys(defaults)
+ .filter(key => key !== 'dashboard')
+ .sort((a, b) => a.localeCompare(b));
+ }
+
+ formatMociFeatureLabel(key) {
+ return String(key || '')
+ .replace(/_/g, ' ')
+ .toUpperCase();
+ }
+
+ async loadMociConfig() {
+ const grid = document.getElementById('moci-features-grid');
+ if (!grid) return;
+
+ try {
+ let values = {};
+ try {
+ const [status, result] = await this.core.uciGet('moci', 'features');
+ if (status === 0 && result?.values) {
+ values = result.values;
+ }
+ } catch {}
+
+ const defaults = this.core.getDefaultFeatures ? this.core.getDefaultFeatures() : {};
+ const featureKeys = this.getMociFeatureKeys();
+ if (featureKeys.length === 0) {
+ grid.innerHTML = 'No MoCI features available.
';
+ return;
+ }
+ grid.innerHTML = featureKeys
+ .map(key => {
+ const value = String(values[key] ?? defaults[key] ?? '0') === '1';
+ return ``;
+ })
+ .join('');
+ } catch {
+ grid.innerHTML = 'Failed to load MoCI config.
';
+ }
+ }
+
+ async saveMociConfig() {
+ const toggles = Array.from(document.querySelectorAll('#moci-features-grid .moci-feature-toggle'));
+ if (toggles.length === 0) {
+ this.core.showToast('No MoCI feature toggles found', 'error');
+ return;
+ }
+
+ const values = {};
+ for (const toggle of toggles) {
+ const key = String(toggle.getAttribute('data-feature-key') || '').trim();
+ if (!key) continue;
+ values[key] = toggle.checked ? '1' : '0';
+ }
+
+ try {
+ await this.core.uciSet('moci', 'features', values);
+ await this.core.uciCommit('moci');
+ await this.core.ubusCall('file', 'exec', {
+ command: '/etc/init.d/uhttpd',
+ params: ['restart']
+ });
+ await this.core.loadFeatures();
+ this.core.applyFeatureFlags();
+ this.core.showToast('MoCI config saved (uhttpd restarted)', 'success');
+ } catch {
+ this.core.showToast('Failed to save MoCI config', 'error');
+ }
+ }
+
async saveGeneral() {
const hostname = document.getElementById('system-hostname').value.trim();
const timezone = document.getElementById('system-timezone').value.trim();
@@ -130,6 +240,46 @@ export default class SystemModule {
}
}
+ async syncBrowserTime() {
+ const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ const epochSec = Math.floor(Date.now() / 1000);
+ const safeZone = String(browserZone).trim();
+ if (!safeZone || !/^[A-Za-z0-9._+\-\/]+$/.test(safeZone)) {
+ this.core.showToast('Browser timezone is invalid', 'error');
+ return;
+ }
+
+ try {
+ await this.core.uciSet('system', '@system[0]', { zonename: safeZone });
+ await this.core.uciCommit('system');
+
+ await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: ['-c', `date -u -s "@${epochSec}"`]
+ });
+
+ try {
+ await this.core.ubusCall('file', 'exec', {
+ command: '/etc/init.d/system',
+ params: ['reload']
+ });
+ } catch {}
+
+ try {
+ await this.core.ubusCall('file', 'exec', {
+ command: '/etc/init.d/sysntpd',
+ params: ['restart']
+ });
+ } catch {}
+
+ const timezoneInput = document.getElementById('system-timezone');
+ if (timezoneInput) timezoneInput.value = safeZone;
+ this.core.showToast(`Router time synced (${safeZone})`, 'success');
+ } catch {
+ this.core.showToast('Failed to sync browser time', 'error');
+ }
+ }
+
async loadAdmin() {}
async changePassword() {
@@ -173,19 +323,34 @@ export default class SystemModule {
async createBackup() {
try {
- const [s] = await this.core.ubusCall(
+ const backupCmd =
+ 'BACKUP_FILE="/tmp/backup-$(cat /proc/sys/kernel/hostname 2>/dev/null || echo openwrt)-$(date +%F-%H%M%S).tar.gz"; ' +
+ 'LOG_FILE="/tmp/moci-backup.log"; ' +
+ 'if /sbin/sysupgrade -b "$BACKUP_FILE" >"$LOG_FILE" 2>&1 || /sbin/sysupgrade --create-backup "$BACKUP_FILE" >"$LOG_FILE" 2>&1; then ' +
+ 'echo "$BACKUP_FILE"; ' +
+ 'else cat "$LOG_FILE" >&2; exit 1; fi';
+
+ const [s, r] = await this.core.ubusCall(
'file',
'exec',
- { command: '/sbin/sysupgrade', params: ['--create-backup', '/tmp/backup.tar.gz'] },
- { timeout: 30000 }
+ { command: '/bin/sh', params: ['-c', backupCmd] },
+ { timeout: 90000 }
);
- if (s !== 0) throw new Error('Backup failed');
+ if (s !== 0) throw new Error((r?.stderr || '').trim() || 'Backup failed');
+
+ const backupPath = String(r?.stdout || '')
+ .trim()
+ .split('\n')
+ .pop();
+ if (!backupPath || !backupPath.startsWith('/tmp/')) {
+ throw new Error('Backup file path not returned');
+ }
const [rs, rr] = await this.core.ubusCall('file', 'read', {
- path: '/tmp/backup.tar.gz',
+ path: backupPath,
base64: true
});
- if (rs !== 0 || !rr?.data) throw new Error('Failed to read backup');
+ if (rs !== 0 || !rr?.data) throw new Error('Failed to read generated backup archive');
const binary = atob(rr.data);
const bytes = new Uint8Array(binary.length);
@@ -195,15 +360,18 @@ export default class SystemModule {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
- a.download = `backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
+ a.download = backupPath.split('/').pop() || `backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
a.click();
URL.revokeObjectURL(url);
this.core.showToast('Backup created', 'success');
- } catch {
- this.core.showToast('Failed to create backup', 'error');
+ } catch (err) {
+ this.core.showToast(`Failed to create backup: ${err?.message || 'unknown error'}`, 'error');
} finally {
try {
- await this.core.ubusCall('file', 'exec', { command: '/bin/rm', params: ['-f', '/tmp/backup.tar.gz'] });
+ await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: ['-c', 'rm -f /tmp/backup-*.tar.gz /tmp/moci-backup.log']
+ });
} catch {}
}
}
@@ -212,11 +380,24 @@ export default class SystemModule {
if (!confirm('This will erase all settings and restore factory defaults. Continue?')) return;
if (!confirm('This action cannot be undone. Are you absolutely sure?')) return;
try {
- await this.core.ubusCall('file', 'exec', { command: '/sbin/firstboot', params: ['-y'] });
+ await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: [
+ '-c',
+ 'if command -v firstboot >/dev/null 2>&1; then firstboot -y; ' +
+ 'elif command -v jffs2reset >/dev/null 2>&1; then jffs2reset -y; ' +
+ 'elif [ -x /sbin/firstboot ]; then /sbin/firstboot -y; ' +
+ 'elif [ -x /sbin/jffs2reset ]; then /sbin/jffs2reset -y; ' +
+ 'else echo "No reset utility found" >&2; exit 127; fi'
+ ]
+ });
this.core.showToast('Factory reset initiated, rebooting...', 'success');
setTimeout(async () => {
try {
- await this.core.ubusCall('system', 'reboot', {});
+ await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: ['-c', 'sleep 2; reboot']
+ });
setTimeout(() => this.core.logout(), 2000);
} catch {}
}, 2000);
@@ -237,42 +418,160 @@ export default class SystemModule {
}
async loadPackages() {
+ await this.updateSoftwareRootSpace();
await this.core.loadResource('packages-table', 3, 'packages', async () => {
- const [status, result] = await this.core.ubusCall('file', 'read', { path: '/usr/lib/opkg/status' });
- if (status !== 0 || !result?.data) throw new Error('No data');
-
- const packages = [];
- for (const block of result.data.split('\n\n')) {
- let pkg = {};
- for (const line of block.split('\n')) {
- if (line.startsWith('Package: ')) pkg.name = line.substring(9);
- else if (line.startsWith('Version: ')) pkg.version = line.substring(9);
+ let packages = [];
+
+ const [opkgStatus, opkgResult] = await this.core.ubusCall('file', 'read', {
+ path: '/usr/lib/opkg/status'
+ });
+ if (opkgStatus === 0 && opkgResult?.data) {
+ packages = this.parseOpkgStatus(opkgResult.data);
+ }
+
+ // OpenWrt apk-based images store installed package metadata in this db.
+ if (packages.length === 0) {
+ const [apkStatus, apkResult] = await this.core.ubusCall('file', 'read', {
+ path: '/lib/apk/db/installed'
+ });
+ if (apkStatus === 0 && apkResult?.data) {
+ packages = this.parseApkInstalledDb(apkResult.data);
}
- if (pkg.name) packages.push(pkg);
}
- const display = packages.slice(0, 100);
- let html = display
- .map(
- p => `
+ (
+ k,
+ i
+ ) => `
+ const tbody = document.querySelector('#mounts-table tbody');
+ if (!tbody) return;
+ if (mounts.length === 0) {
+ this.core.renderEmptyTable(tbody, 6, 'No configured or mounted storage');
+ return;
+ }
+ tbody.innerHTML = mounts
+ .map(
+ m => `
${this.core.escapeHtml(m.device)}
${this.core.escapeHtml(m.mountPoint)}
- N/A
+ ${this.core.escapeHtml(m.filesystem || 'N/A')}
${this.core.escapeHtml(m.size)}
${this.core.escapeHtml(m.used)}
${this.core.escapeHtml(m.available)}
`
- );
+ )
+ .join('');
});
}
+ isStorageMountPoint(path) {
+ const p = String(path || '');
+ if (!p) return false;
+ if (p === '/') return false;
+ if (p.startsWith('/proc')) return false;
+ if (p.startsWith('/sys')) return false;
+ if (p.startsWith('/dev')) return false;
+ if (p.startsWith('/tmp')) return false;
+ if (p.startsWith('/run')) return false;
+ return true;
+ }
+
+ async readConfiguredMounts() {
+ try {
+ const [status, result] = await this.core.uciGet('fstab');
+ if (status !== 0 || !result?.values) return [];
+ return Object.entries(result.values)
+ .filter(([, v]) => v?.['.type'] === 'mount')
+ .map(([section, v]) => ({
+ section,
+ device: v.device || v.uuid || v.label || section,
+ mountPoint: v.target || '',
+ filesystem: v.fstype || '',
+ enabled: String(v.enabled || '1') !== '0'
+ }))
+ .filter(m => m.mountPoint);
+ } catch {
+ return [];
+ }
+ }
+
+ async readRuntimeMounts() {
+ try {
+ const [status, result] = await this.core.ubusCall('file', 'read', { path: '/proc/mounts' });
+ if (status !== 0 || !result?.data) return [];
+ return String(result.data)
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean)
+ .map(line => {
+ const parts = line.split(/\s+/);
+ return {
+ device: parts[0] || '',
+ mountPoint: parts[1] || '',
+ filesystem: parts[2] || '',
+ isMounted: true
+ };
+ })
+ .filter(m => m.mountPoint);
+ } catch {
+ return [];
+ }
+ }
+
+ async readMountUsageByMountPoint() {
+ try {
+ const [status, result] = await this.core.ubusCall('file', 'exec', {
+ command: '/bin/sh',
+ params: ['-c', 'df -h -P 2>/dev/null || /bin/df -h -P 2>/dev/null || /usr/bin/df -h -P 2>/dev/null']
+ });
+ if (status !== 0 || !result?.stdout) return new Map();
+ const lines = String(result.stdout)
+ .split('\n')
+ .slice(1)
+ .map(l => l.trim())
+ .filter(Boolean);
+ const map = new Map();
+ for (const line of lines) {
+ const parts = line.split(/\s+/);
+ if (parts.length < 6) continue;
+ map.set(parts[5], {
+ device: parts[0],
+ size: parts[1],
+ used: parts[2],
+ available: parts[3],
+ usePercent: parts[4]
+ });
+ }
+ return map;
+ } catch {
+ return new Map();
+ }
+ }
+
+ buildMountRows(configured, runtime, usageByMountPoint) {
+ const rows = [];
+ const byMountPoint = new Map();
+
+ for (const r of runtime || []) {
+ const usage = usageByMountPoint.get(r.mountPoint) || {};
+ const row = {
+ device: usage.device || r.device || 'N/A',
+ mountPoint: r.mountPoint || 'N/A',
+ filesystem: r.filesystem || 'N/A',
+ size: usage.size || 'N/A',
+ used: usage.used || 'N/A',
+ available: usage.available || 'N/A',
+ usePercent: usage.usePercent || '0%',
+ isMounted: true
+ };
+ byMountPoint.set(row.mountPoint, row);
+ rows.push(row);
+ }
+
+ for (const c of configured || []) {
+ if (byMountPoint.has(c.mountPoint)) continue;
+ rows.push({
+ device: c.device || 'N/A',
+ mountPoint: c.mountPoint || 'N/A',
+ filesystem: c.filesystem || 'N/A',
+ size: 'N/A',
+ used: c.enabled ? 'N/A' : 'Disabled',
+ available: 'N/A',
+ usePercent: '0%',
+ isMounted: false
+ });
+ }
+
+ rows.sort((a, b) => String(a.mountPoint).localeCompare(String(b.mountPoint)));
+ return rows;
+ }
+
async loadLED() {
await this.core.loadResource('led-table', 3, 'leds', async () => {
const [status, result] = await this.core.uciGet('system');
if (status !== 0 || !result?.values) throw new Error('No data');
- const leds = this.core.filterUciSections(result.values, 'led');
- this.core.renderTable(
- '#led-table',
- leds,
- 3,
- 'No LEDs configured',
- l => `
+ const leds = Object.entries(result.values)
+ .filter(([, v]) => v['.type'] === 'led')
+ .map(([k, v]) => ({ section: k, ...v }));
+
+ const tbody = document.querySelector('#led-table tbody');
+ if (!tbody) return;
+ if (leds.length === 0) {
+ this.core.renderEmptyTable(tbody, 3, 'No LEDs configured');
+ return;
+ }
+ tbody.innerHTML = leds
+ .map(
+ l => `
${this.core.escapeHtml(l.sysfs || l.section)}
${this.core.escapeHtml(l.trigger || 'default-on')}
${this.core.renderBadge('info', 'CONFIGURED')}
`
- );
+ )
+ .join('');
});
}
setupFirmwareUpload() {
const fileInput = document.getElementById('firmware-file');
const uploadArea = document.getElementById('file-upload-area');
+ const uploadText = document.getElementById('file-upload-text');
const validateBtn = document.getElementById('validate-firmware-btn');
const flashBtn = document.getElementById('flash-firmware-btn');
@@ -672,11 +1213,15 @@ export default class SystemModule {
uploadArea.addEventListener('drop', e => {
e.preventDefault();
uploadArea.style.borderColor = 'var(--slate-border)';
- if (e.dataTransfer.files.length) this.handleFirmwareFile(e.dataTransfer.files[0]);
+ if (e.dataTransfer.files.length) {
+ this.handleFirmwareFile(e.dataTransfer.files[0]);
+ }
});
fileInput.addEventListener('change', () => {
- if (fileInput.files.length) this.handleFirmwareFile(fileInput.files[0]);
+ if (fileInput.files.length) {
+ this.handleFirmwareFile(fileInput.files[0]);
+ }
});
validateBtn?.addEventListener('click', () => this.validateFirmware());
diff --git a/moci/js/modules/vpn.js b/moci/js/modules/vpn.js
new file mode 100644
index 0000000..060b86b
--- /dev/null
+++ b/moci/js/modules/vpn.js
@@ -0,0 +1,73 @@
+export default class VPNModule {
+ constructor(core) {
+ this.core = core;
+ }
+
+ async fetchNetworkConfig() {
+ const [status, result] = await this.core.uciGet('network');
+
+ if (status !== 0 || !result?.values) {
+ throw new Error('Failed to fetch network config');
+ }
+
+ return result.values;
+ }
+
+ parseWireGuardInterfaces(config) {
+ return Object.entries(config)
+ .filter(([key, val]) => val.proto === 'wireguard')
+ .map(([key, val]) => ({
+ name: key,
+ ...val
+ }));
+ }
+
+ renderWireGuardRow(iface) {
+ const enabled = iface.disabled !== '1';
+ const statusBadge = enabled
+ ? this.core.renderBadge('success', 'ENABLED')
+ : this.core.renderBadge('error', 'DISABLED');
+
+ return `
+
+ ${this.core.escapeHtml(iface.name)}
+ ${this.core.escapeHtml(iface.private_key?.substring(0, 20) || 'N/A')}...
+ ${this.core.escapeHtml(iface.listen_port || 'N/A')}
+ ${statusBadge}
+
+ `;
+ }
+
+ renderWireGuardTable(interfaces) {
+ return interfaces.map(iface => this.renderWireGuardRow(iface)).join('');
+ }
+
+ updateWireGuardTable(interfaces) {
+ const tbody = document.querySelector('#wireguard-table tbody');
+ if (!tbody) return;
+
+ if (interfaces.length === 0) {
+ this.core.renderEmptyTable(tbody, 4, 'No WireGuard interfaces configured');
+ return;
+ }
+
+ tbody.innerHTML = this.renderWireGuardTable(interfaces);
+ }
+
+ async loadWireGuard() {
+ if (!this.core.isFeatureEnabled('wireguard')) return;
+
+ try {
+ const config = await this.fetchNetworkConfig();
+ const interfaces = this.parseWireGuardInterfaces(config);
+ this.updateWireGuardTable(interfaces);
+ } catch (err) {
+ console.error('Failed to load WireGuard config:', err);
+ this.core.showToast('Failed to load WireGuard configuration', 'error');
+ const tbody = document.querySelector('#wireguard-table tbody');
+ if (tbody) {
+ this.core.renderEmptyTable(tbody, 4, 'Failed to load WireGuard configuration');
+ }
+ }
+ }
+}
diff --git a/rpcd-acl.json b/rpcd-acl.json
index 1d5b5aa..246b4d7 100644
--- a/rpcd-acl.json
+++ b/rpcd-acl.json
@@ -8,9 +8,14 @@
"/proc/net/dev": ["read"],
"/proc/net/arp": ["read"],
"/proc/stat": ["read"],
+ "/proc/sys/net/netfilter/nf_conntrack_count": ["read"],
+ "/proc/sys/net/netfilter/nf_conntrack_max": ["read"],
"/etc/crontabs/root": ["read"],
"/etc/dropbear/authorized_keys": ["read"],
"/etc/hosts": ["read"],
+ "/tmp/moci-ping-monitor.txt": ["read"],
+ "/tmp/moci-speedtest-monitor.txt": ["read"],
+ "/tmp/moci-netify.sqlite": ["read"],
"/tmp/firmware.bin": ["read"],
"/tmp/backup.tar.gz": ["read"]
},
@@ -26,6 +31,22 @@
"uci": ["moci"]
},
"write": {
+ "file": {
+ "/bin/sh": ["exec"],
+ "/bin/rm": ["exec"],
+ "/etc/init.d/netify-collector": ["exec"],
+ "/etc/init.d/ping-monitor": ["exec"],
+ "/etc/init.d/dnsmasq": ["exec"],
+ "/etc/init.d/firewall": ["exec"],
+ "/usr/bin/moci-netify-collector": ["exec"],
+ "/usr/bin/moci-speedtest-monitor": ["exec"],
+ "/usr/bin/sqlite3": ["exec"],
+ "/usr/bin/sqlite3-cli": ["exec"],
+ "/etc/crontabs/root": ["write"],
+ "/tmp/moci-netify.sqlite": ["write"],
+ "/tmp/moci-ping-monitor.txt": ["write"],
+ "/tmp/moci-speedtest-monitor.txt": ["write"]
+ },
"ubus": {
"uci": ["set", "add", "delete", "commit"],
"file": ["write", "exec"],
@@ -39,7 +60,8 @@
"dhcp",
"system",
"qos",
- "ddns"
+ "ddns",
+ "moci"
]
}
}
diff --git a/screenshots/moci-adblock-fast.png b/screenshots/moci-adblock-fast.png
new file mode 100644
index 0000000..19707b2
Binary files /dev/null and b/screenshots/moci-adblock-fast.png differ
diff --git a/screenshots/moci-connections.png b/screenshots/moci-connections.png
new file mode 100644
index 0000000..a7a4017
Binary files /dev/null and b/screenshots/moci-connections.png differ
diff --git a/screenshots/moci-dashboard.png b/screenshots/moci-dashboard.png
new file mode 100644
index 0000000..02f5041
Binary files /dev/null and b/screenshots/moci-dashboard.png differ
diff --git a/screenshots/moci-devices.png b/screenshots/moci-devices.png
new file mode 100644
index 0000000..f8c126b
Binary files /dev/null and b/screenshots/moci-devices.png differ
diff --git a/screenshots/moci-monitoring.png b/screenshots/moci-monitoring.png
new file mode 100644
index 0000000..6b069bf
Binary files /dev/null and b/screenshots/moci-monitoring.png differ
diff --git a/screenshots/moci-netify.png b/screenshots/moci-netify.png
new file mode 100644
index 0000000..7b24d33
Binary files /dev/null and b/screenshots/moci-netify.png differ
diff --git a/screenshots/moci-network.png b/screenshots/moci-network.png
new file mode 100644
index 0000000..ad68e79
Binary files /dev/null and b/screenshots/moci-network.png differ
diff --git a/screenshots/moci-settings.png b/screenshots/moci-settings.png
new file mode 100644
index 0000000..923cc22
Binary files /dev/null and b/screenshots/moci-settings.png differ
diff --git a/scripts/build.js b/scripts/build.js
index 527def8..5c7cdac 100644
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -11,8 +11,13 @@ async function buildJS() {
const files = [
'moci/js/core.js',
'moci/js/modules/dashboard.js',
+ 'moci/js/modules/devices.js',
'moci/js/modules/network.js',
- 'moci/js/modules/system.js'
+ 'moci/js/modules/monitoring.js',
+ 'moci/js/modules/system.js',
+ 'moci/js/modules/vpn.js',
+ 'moci/js/modules/services.js',
+ 'moci/js/modules/netify.js'
];
await mkdir(join(distDir, 'js/modules'), { recursive: true });
diff --git a/scripts/setup-openwrt-router.sh b/scripts/setup-openwrt-router.sh
new file mode 100755
index 0000000..c65afb5
--- /dev/null
+++ b/scripts/setup-openwrt-router.sh
@@ -0,0 +1,269 @@
+#!/bin/sh
+
+# MoCI OpenWrt bootstrap script
+# Intended for fresh/new routers and safe to rerun.
+
+set -u
+
+if [ "$(id -u)" != "0" ]; then
+ echo "Run as root."
+ exit 1
+fi
+
+log() {
+ echo "[moci-setup] $*"
+}
+
+have_cmd() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+SCRIPT_PATH="$0"
+SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" 2>/dev/null && pwd)"
+REPO_DIR="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd)"
+
+require_file() {
+ if [ ! -f "$1" ]; then
+ echo "Missing required file: $1"
+ exit 1
+ fi
+}
+
+install_file() {
+ src="$1"
+ dst="$2"
+ mode="$3"
+ cp "$src" "$dst"
+ chmod "$mode" "$dst"
+ log "Installed $dst"
+}
+
+install_pkg_if_available() {
+ pkg="$1"
+ case "$PKG_MGR" in
+ opkg)
+ if opkg list-installed | grep -q "^$pkg -"; then
+ log "Package already installed: $pkg"
+ return 0
+ fi
+ if ! opkg list | grep -q "^$pkg -"; then
+ log "Package unavailable in current feed, skipping: $pkg"
+ return 1
+ fi
+ opkg install "$pkg" && log "Installed package: $pkg" && return 0
+ log "Failed to install package: $pkg"
+ return 1
+ ;;
+ apk)
+ if apk info -e "$pkg" >/dev/null 2>&1; then
+ log "Package already installed: $pkg"
+ return 0
+ fi
+ if ! apk search -x "$pkg" >/dev/null 2>&1; then
+ log "Package unavailable in current feed, skipping: $pkg"
+ return 1
+ fi
+ apk add "$pkg" && log "Installed package: $pkg" && return 0
+ log "Failed to install package: $pkg"
+ return 1
+ ;;
+ *)
+ log "No supported package manager available; skipping package: $pkg"
+ return 1
+ ;;
+ esac
+}
+
+install_first_available_pkg() {
+ label="$1"
+ shift
+ for candidate in "$@"; do
+ [ -n "$candidate" ] || continue
+ if install_pkg_if_available "$candidate"; then
+ log "Using package for $label: $candidate"
+ return 0
+ fi
+ done
+ log "No installable package found for $label"
+ return 1
+}
+
+set_uci() {
+ key="$1"
+ value="$2"
+ value="${value#\'}"
+ value="${value%\'}"
+ value="${value#\"}"
+ value="${value%\"}"
+ uci set "$key=$value"
+}
+
+require_file "$REPO_DIR/files/moci-netify-collector.sh"
+require_file "$REPO_DIR/files/moci-ping-monitor.sh"
+require_file "$REPO_DIR/files/moci-speedtest-monitor.sh"
+require_file "$REPO_DIR/files/netify-collector.init"
+require_file "$REPO_DIR/files/ping-monitor.init"
+require_file "$REPO_DIR/files/moci.config"
+require_file "$REPO_DIR/rpcd-acl.json"
+require_file "$REPO_DIR/moci/index.html"
+
+log "Updating package feeds"
+PKG_MGR=""
+if have_cmd opkg; then
+ PKG_MGR="opkg"
+ opkg update
+elif have_cmd apk; then
+ PKG_MGR="apk"
+ apk update
+else
+ log "No supported package manager found (opkg/apk). Package install steps will be skipped."
+fi
+
+for pkg in \
+ nano \
+ htop \
+ gawk \
+ grep \
+ sed \
+ coreutils-sort \
+ uhttpd-mod-ubus \
+ netifyd \
+ vnstat2 \
+ vnstati2 \
+ luci-app-vnstat2 \
+ nlbwmon \
+ luci-app-nlbwmon \
+ adblock-fast \
+ luci-app-adblock-fast \
+ speedtestcpp
+do
+ install_pkg_if_available "$pkg"
+done
+
+# Dependency package names can vary between opkg and apk feeds.
+install_first_available_pkg "netcat" netcat netcat-openbsd
+install_first_available_pkg "sqlite-cli" sqlite3-cli sqlite3
+
+log "Deploying MoCI web app"
+mkdir -p /www/moci
+cp -r "$REPO_DIR/moci/"* /www/moci/
+
+log "Installing ACL"
+install_file "$REPO_DIR/rpcd-acl.json" /usr/share/rpcd/acl.d/moci.json 0644
+
+log "Installing backend workers and init scripts"
+install_file "$REPO_DIR/files/moci-netify-collector.sh" /usr/bin/moci-netify-collector 0755
+install_file "$REPO_DIR/files/moci-ping-monitor.sh" /usr/bin/moci-ping-monitor 0755
+install_file "$REPO_DIR/files/moci-speedtest-monitor.sh" /usr/bin/moci-speedtest-monitor 0755
+install_file "$REPO_DIR/files/netify-collector.init" /etc/init.d/netify-collector 0755
+install_file "$REPO_DIR/files/ping-monitor.init" /etc/init.d/ping-monitor 0755
+
+if [ -f /etc/config/moci ]; then
+ cp /etc/config/moci "/etc/config/moci.bak.$(date +%Y%m%d%H%M%S)"
+ log "Backed up existing /etc/config/moci"
+fi
+install_file "$REPO_DIR/files/moci.config" /etc/config/moci 0644
+
+log "Setting uhttpd home to /www"
+set_uci uhttpd.main.home "/www"
+uci commit uhttpd
+
+log "Applying MoCI runtime defaults"
+set_uci moci.collector.enabled "1"
+set_uci moci.collector.host "127.0.0.1"
+set_uci moci.collector.port "7150"
+set_uci moci.collector.db_path "/tmp/moci-netify.sqlite"
+set_uci moci.collector.retention_rows "500000"
+set_uci moci.collector.stream_timeout "45"
+set_uci moci.ping_monitor.enabled "1"
+set_uci moci.ping_monitor.target "1.1.1.1"
+set_uci moci.ping_monitor.interval "60"
+set_uci moci.ping_monitor.threshold "100"
+set_uci moci.ping_monitor.timeout "2"
+set_uci moci.ping_monitor.output_file "/tmp/moci-ping-monitor.txt"
+set_uci moci.ping_monitor.max_lines "2000"
+set_uci moci.speedtest_monitor.enabled "1"
+set_uci moci.speedtest_monitor.run_hour "3"
+set_uci moci.speedtest_monitor.run_minute "15"
+set_uci moci.speedtest_monitor.output_file "/tmp/moci-speedtest-monitor.txt"
+set_uci moci.speedtest_monitor.max_lines "365"
+uci commit moci
+
+NETIFYD_CONF="/etc/netifyd.conf"
+if [ -f "$NETIFYD_CONF" ]; then
+ if grep -q "^listen_address\[0\]" "$NETIFYD_CONF"; then
+ sed -i "s|^listen_address\[0\].*|listen_address[0] = 127.0.0.1|" "$NETIFYD_CONF"
+ else
+ grep -q "^\[socket\]" "$NETIFYD_CONF" || echo "[socket]" >>"$NETIFYD_CONF"
+ sed -i "/^\[socket\]/a listen_address[0] = 127.0.0.1" "$NETIFYD_CONF"
+ fi
+ log "Updated netifyd listen_address[0] to 127.0.0.1"
+fi
+
+NLBW_CONF="/etc/config/nlbwmon"
+if [ -f "$NLBW_CONF" ]; then
+ sed -i "s/option refresh_interval '30s'/option refresh_interval '10s'/" "$NLBW_CONF"
+ sed -i "s/option refresh_interval 30s/option refresh_interval 10s/" "$NLBW_CONF"
+ log "Set nlbwmon refresh_interval to 10s"
+fi
+
+log "Initializing data files"
+/usr/bin/moci-netify-collector --init-db || true
+/usr/bin/moci-ping-monitor --once || true
+/usr/bin/moci-speedtest-monitor --init-file || true
+
+if ! have_cmd nc; then
+ log "WARNING: nc command not found; netify collector will not ingest flows."
+fi
+if ! have_cmd sqlite3 && ! have_cmd sqlite3-cli; then
+ log "WARNING: sqlite3/sqlite3-cli not found; netify collector and UI sqlite queries will fail."
+fi
+
+log "Enabling and restarting services"
+/etc/init.d/rpcd restart || true
+/etc/init.d/uhttpd restart || true
+
+log "Applying daily speedtest cron schedule"
+SPEEDTEST_MARKER="# MOCI_SPEEDTEST_MONITOR"
+CRON_PATH="/etc/crontabs/root"
+TMP_CRON="/tmp/.moci_cron.$$"
+HOUR="$(uci -q get moci.speedtest_monitor.run_hour 2>/dev/null || echo 3)"
+MINUTE="$(uci -q get moci.speedtest_monitor.run_minute 2>/dev/null || echo 15)"
+ENABLED="$(uci -q get moci.speedtest_monitor.enabled 2>/dev/null || echo 1)"
+case "$HOUR" in ''|*[!0-9]*) HOUR=3 ;; esac
+case "$MINUTE" in ''|*[!0-9]*) MINUTE=15 ;; esac
+if [ "$HOUR" -gt 23 ]; then HOUR=3; fi
+if [ "$MINUTE" -gt 59 ]; then MINUTE=15; fi
+if [ -f "$CRON_PATH" ]; then
+ grep -v "$SPEEDTEST_MARKER" "$CRON_PATH" >"$TMP_CRON" 2>/dev/null || : >"$TMP_CRON"
+else
+ : >"$TMP_CRON"
+fi
+if [ "$ENABLED" = "1" ]; then
+ echo "$MINUTE $HOUR * * * /usr/bin/moci-speedtest-monitor --once >/tmp/moci-speedtest-monitor.last.log 2>&1 $SPEEDTEST_MARKER" >>"$TMP_CRON"
+fi
+cp "$TMP_CRON" "$CRON_PATH"
+rm -f "$TMP_CRON"
+/bin/sh -c '/etc/init.d/cron reload 2>/dev/null || /etc/init.d/cron restart 2>/dev/null || /etc/init.d/crond reload 2>/dev/null || /etc/init.d/crond restart 2>/dev/null || killall -HUP crond 2>/dev/null || true'
+
+for svc in vnstat nlbwmon netifyd netify-collector ping-monitor; do
+ if [ -x "/etc/init.d/$svc" ]; then
+ /etc/init.d/"$svc" enable || true
+ /etc/init.d/"$svc" restart || true
+ log "Service restarted: $svc"
+ fi
+done
+
+log "Finalizing ACL and web server settings"
+cp "$REPO_DIR/rpcd-acl.json" /usr/share/rpcd/acl.d/moci.json
+/etc/init.d/rpcd restart || true
+/etc/init.d/uhttpd restart || true
+uci set uhttpd.main.home='/www'
+uci commit uhttpd
+/etc/init.d/uhttpd restart || true
+
+log "Setup complete."
+log "Open: http://$(uci -q get network.lan.ipaddr 2>/dev/null || echo 192.168.1.1)/moci/"
+log "Log out/in after ACL changes to refresh ubus session permissions."
+
+exit 0
diff --git a/scripts/watch.js b/scripts/watch.js
index d4a0fa5..d809de7 100644
--- a/scripts/watch.js
+++ b/scripts/watch.js
@@ -52,6 +52,15 @@ const aclWatcher = chokidar.watch('rpcd-acl.json', {
}
});
+const serviceWatcher = chokidar.watch(['files/moci-ping-monitor.sh', 'files/ping-monitor.init', 'files/moci-speedtest-monitor.sh'], {
+ persistent: true,
+ ignoreInitial: true,
+ awaitWriteFinish: {
+ stabilityThreshold: 300,
+ pollInterval: 100
+ }
+});
+
watcher.on('all', (event, path) => {
console.log(`[${event}] ${path}`);
if (event === 'change' || event === 'add') {
@@ -66,6 +75,13 @@ aclWatcher.on('all', (event, path) => {
}
});
+serviceWatcher.on('all', (event, path) => {
+ console.log(`[${event}] ${path}`);
+ if (event === 'change' || event === 'add') {
+ deployPingService();
+ }
+});
+
function deploy() {
try {
console.log(`Deploying to ${targetName}...`);
@@ -79,12 +95,25 @@ function deploy() {
execSync(`cat moci/js/modules/dashboard.js | ${SSH} "cat > /www/moci/js/modules/dashboard.js"`, {
stdio: 'pipe'
});
+ execSync(`cat moci/js/modules/devices.js | ${SSH} "cat > /www/moci/js/modules/devices.js"`, {
+ stdio: 'pipe'
+ });
execSync(`cat moci/js/modules/network.js | ${SSH} "cat > /www/moci/js/modules/network.js"`, {
stdio: 'pipe'
});
+ execSync(`cat moci/js/modules/monitoring.js | ${SSH} "cat > /www/moci/js/modules/monitoring.js"`, {
+ stdio: 'pipe'
+ });
execSync(`cat moci/js/modules/system.js | ${SSH} "cat > /www/moci/js/modules/system.js"`, {
stdio: 'pipe'
});
+ execSync(`cat moci/js/modules/vpn.js | ${SSH} "cat > /www/moci/js/modules/vpn.js"`, { stdio: 'pipe' });
+ execSync(`cat moci/js/modules/services.js | ${SSH} "cat > /www/moci/js/modules/services.js"`, {
+ stdio: 'pipe'
+ });
+ execSync(`cat moci/js/modules/netify.js | ${SSH} "cat > /www/moci/js/modules/netify.js"`, {
+ stdio: 'pipe'
+ });
console.log('Deployed successfully\n');
} catch (err) {
@@ -105,4 +134,25 @@ function deployACL() {
}
}
+function deployPingService() {
+ try {
+ console.log(`Deploying ping monitor service to ${targetName}...`);
+
+ execSync(`cat files/moci-ping-monitor.sh | ${SSH} "cat > /usr/bin/moci-ping-monitor && chmod +x /usr/bin/moci-ping-monitor"`, {
+ stdio: 'pipe'
+ });
+ execSync(`cat files/ping-monitor.init | ${SSH} "cat > /etc/init.d/ping-monitor && chmod +x /etc/init.d/ping-monitor"`, {
+ stdio: 'pipe'
+ });
+ execSync(`cat files/moci-speedtest-monitor.sh | ${SSH} "cat > /usr/bin/moci-speedtest-monitor && chmod +x /usr/bin/moci-speedtest-monitor"`, {
+ stdio: 'pipe'
+ });
+ execSync(`${SSH} "/etc/init.d/ping-monitor enable || true; /etc/init.d/ping-monitor restart"`, { stdio: 'pipe' });
+
+ console.log('Ping monitor service deployed and restarted\n');
+ } catch (err) {
+ console.error('Ping service deploy failed:', err.message);
+ }
+}
+
console.log('Ready. Save files in moci/ or rpcd-acl.json to trigger deploy.');
${this.core.escapeHtml(k.type)} ${this.core.escapeHtml(k.comment || 'No comment')}
@@ -578,81 +993,207 @@ export default class SystemModule {
async loadMounts() {
await this.core.loadResource('mounts-table', 6, 'storage', async () => {
- const [s, r] = await this.core.ubusCall('file', 'exec', { command: '/bin/df', params: ['-h'] });
- if (s !== 0 || !r?.stdout) throw new Error('No data');
+ const configured = await this.readConfiguredMounts();
+ const runtime = await this.readRuntimeMounts();
+ const usageByMountPoint = await this.readMountUsageByMountPoint();
- const mounts = r.stdout
- .split('\n')
- .slice(1)
- .filter(l => l.trim())
- .map(line => {
- const parts = line.trim().split(/\s+/);
- return {
- device: parts[0],
- size: parts[1],
- used: parts[2],
- available: parts[3],
- usePercent: parts[4],
- mountPoint: parts[5]
- };
- });
+ const mounts = this.buildMountRows(configured, runtime, usageByMountPoint);
const charts = document.getElementById('storage-charts');
if (charts) {
- charts.innerHTML = mounts
- .filter(m => m.mountPoint !== '/dev')
- .map(
- m => `
-
`
- )
- .join('');
+ const chartRows = mounts.filter(m => m.isMounted && this.isStorageMountPoint(m.mountPoint));
+ if (chartRows.length === 0) {
+ charts.innerHTML =
+ '${this.core.escapeHtml(m.mountPoint)}
-
-
-
- ${this.core.escapeHtml(m.used)} / ${this.core.escapeHtml(m.size)} (${this.core.escapeHtml(m.usePercent)})
- No mounted storage devices detected.
';
+ } else {
+ charts.innerHTML = chartRows
+ .map(
+ m => `
+
`
+ )
+ .join('');
+ }
}
- this.core.renderTable(
- '#mounts-table',
- mounts,
- 6,
- 'No mount points',
- m => `${this.core.escapeHtml(m.mountPoint)}
+
+
+
+ ${this.core.escapeHtml(m.used)} / ${this.core.escapeHtml(m.size)} (${this.core.escapeHtml(m.usePercent)})
+