From 9d2cc4e2da00f9cfb26934eeac675cf384e4d831 Mon Sep 17 00:00:00 2001 From: benisai Date: Tue, 24 Mar 2026 17:53:18 -0700 Subject: [PATCH] uploading files for PR --- Makefile | 13 + QUICK | 0 README.BI.md | 350 +++ files/moci-netify-collector.sh | 245 ++ files/moci-ping-monitor.sh | 141 ++ files/moci-speedtest-monitor.sh | 150 ++ files/moci.config | 37 + files/netify-collector.init | 24 + files/ping-monitor.init | 24 + index.html | 170 +- moci/app.css | 620 +++-- moci/app.js | 3944 +++++++++++++++++++++++++++++ moci/index.html | 866 ++++++- moci/js/core.js | 182 +- moci/js/modules/dashboard.js | 1113 ++++++-- moci/js/modules/devices.js | 727 ++++++ moci/js/modules/monitoring.js | 913 +++++++ moci/js/modules/netify.js | 1125 ++++++++ moci/js/modules/network.js | 1606 +++++++++--- moci/js/modules/services.js | 140 + moci/js/modules/system.js | 877 +++++-- moci/js/modules/vpn.js | 73 + rpcd-acl.json | 24 +- screenshots/moci-adblock-fast.png | Bin 0 -> 2205228 bytes screenshots/moci-connections.png | Bin 0 -> 1326235 bytes screenshots/moci-dashboard.png | Bin 0 -> 2631407 bytes screenshots/moci-devices.png | Bin 0 -> 1170497 bytes screenshots/moci-monitoring.png | Bin 0 -> 1590464 bytes screenshots/moci-netify.png | Bin 0 -> 1913938 bytes screenshots/moci-network.png | Bin 0 -> 1102152 bytes screenshots/moci-settings.png | Bin 0 -> 1295188 bytes scripts/build.js | 7 +- scripts/setup-openwrt-router.sh | 269 ++ scripts/watch.js | 50 + 34 files changed, 12526 insertions(+), 1164 deletions(-) create mode 100644 QUICK create mode 100644 README.BI.md create mode 100755 files/moci-netify-collector.sh create mode 100755 files/moci-ping-monitor.sh create mode 100644 files/moci-speedtest-monitor.sh create mode 100755 files/netify-collector.init create mode 100755 files/ping-monitor.init create mode 100644 moci/app.js create mode 100644 moci/js/modules/devices.js create mode 100644 moci/js/modules/monitoring.js create mode 100644 moci/js/modules/netify.js create mode 100644 moci/js/modules/services.js create mode 100644 moci/js/modules/vpn.js create mode 100644 screenshots/moci-adblock-fast.png create mode 100644 screenshots/moci-connections.png create mode 100644 screenshots/moci-dashboard.png create mode 100644 screenshots/moci-devices.png create mode 100644 screenshots/moci-monitoring.png create mode 100644 screenshots/moci-netify.png create mode 100644 screenshots/moci-network.png create mode 100644 screenshots/moci-settings.png create mode 100755 scripts/setup-openwrt-router.sh diff --git a/Makefile b/Makefile index 86d8bdc..1abb53c 100644 --- a/Makefile +++ b/Makefile @@ -36,14 +36,27 @@ define Package/moci/install $(INSTALL_DIR) $(1)/www/moci/js/modules $(INSTALL_DATA) ./dist/moci/js/modules/dashboard.js $(1)/www/moci/js/modules/ + $(INSTALL_DATA) ./dist/moci/js/modules/devices.js $(1)/www/moci/js/modules/ $(INSTALL_DATA) ./dist/moci/js/modules/network.js $(1)/www/moci/js/modules/ + $(INSTALL_DATA) ./dist/moci/js/modules/monitoring.js $(1)/www/moci/js/modules/ $(INSTALL_DATA) ./dist/moci/js/modules/system.js $(1)/www/moci/js/modules/ + $(INSTALL_DATA) ./dist/moci/js/modules/vpn.js $(1)/www/moci/js/modules/ + $(INSTALL_DATA) ./dist/moci/js/modules/services.js $(1)/www/moci/js/modules/ + $(INSTALL_DATA) ./dist/moci/js/modules/netify.js $(1)/www/moci/js/modules/ $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d $(INSTALL_DATA) ./rpcd-acl.json $(1)/usr/share/rpcd/acl.d/moci.json $(INSTALL_DIR) $(1)/etc/config $(INSTALL_CONF) ./files/moci.config $(1)/etc/config/moci + + $(INSTALL_DIR) $(1)/usr/bin + $(INSTALL_BIN) ./files/moci-netify-collector.sh $(1)/usr/bin/moci-netify-collector + $(INSTALL_BIN) ./files/moci-ping-monitor.sh $(1)/usr/bin/moci-ping-monitor + + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/netify-collector.init $(1)/etc/init.d/netify-collector + $(INSTALL_BIN) ./files/ping-monitor.init $(1)/etc/init.d/ping-monitor endef define Package/moci/postinst diff --git a/QUICK b/QUICK new file mode 100644 index 0000000..e69de29 diff --git a/README.BI.md b/README.BI.md new file mode 100644 index 0000000..f5b5c88 --- /dev/null +++ b/README.BI.md @@ -0,0 +1,350 @@ +
+ +# MoCI + +**Modern Configuration Interface for OpenWrt** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) + +[Demo](https://hudsongraeme.github.io/MoCI/) • [Install](#installation) • [Features](#features) + +
+ +### MoCI Dashboard + +![MoCI Dashboard](https://github.com/benisai/codex-moci/blob/main/screenshots/moci-dashboard.png) + +### MoCI Devices + +![MoCI Devices](https://github.com/benisai/codex-moci/blob/main/screenshots/moci-devices.png) + +### MoCI Netify + +![MoCI Netify](https://github.com/benisai/codex-moci/blob/main/screenshots/moci-netify.png) + +### MoCI Monitoring + +![MoCI Monitoring](https://github.com/benisai/codex-moci/blob/main/screenshots/moci-monitoring.png) + +### MoCI Settings + +![MoCI Settings](https://github.com/benisai/codex-moci/blob/main/screenshots/moci-settings.png) +
+
+ +--- + +## What is this? + +A complete standalone web interface for OpenWrt routers. Not a LuCI theme—pure vanilla JavaScript SPA using OpenWrt's native ubus API. + +```bash +scp -r moci/* root@192.168.1.1:/www/moci/ +# Access at http://192.168.1.1/moci/ +``` + +--- + +## Features + + + + + + +
+ +### Dashboard +- Live system stats & graphs +- Network traffic monitoring +- System logs +- Active connections +- Quick actions + +### Network +- Interface configuration +- Wireless management (SSID, encryption) +- Firewall & port forwarding +- DHCP leases (active + static) +- Diagnostics (ping, traceroute, WOL) + + + +### System +- Hostname & timezone +- Password management +- Backup & restore +- Package management +- Service control +- Init script management + +### Design +- Dark glassmorphic UI +- Responsive tables +- Real-time updates +- Toast notifications +- Smooth animations + +
+ +--- + +## Installation +### Manual Install + +```bash +opkg update or apk update +opkg install / apk add git git-http ca-bundle nano +git clone https://github.com/benisai/codex-moci.git +cd codex-moci +sh scripts/setup-openwrt-router.sh + +cp rpcd-acl.json /usr/share/rpcd/acl.d/moci.json +/etc/init.d/rpcd restart +uci set uhttpd.main.home='/www' +uci commit uhttpd +/etc/init.d/uhttpd restart +``` + +**What the ACL grants:** +- WAN/LAN status display on dashboard +- Bandwidth monitoring +- Device count +- Package list viewing in Software tab + +Access at `http://192.168.1.1/moci/` and login with your root credentials. + + +What does the shell script do?: +- installs required packages (`netifyd`, `netcat`, `vnstat`, `nlbwmon`, `speedtestcpp`, etc.) +- deploys web UI to `/www/moci` +- installs/updates `rpcd` ACL (`/usr/share/rpcd/acl.d/moci.json`) +- installs backend workers + init scripts: + - `/usr/bin/moci-netify-collector` + - `/usr/bin/moci-ping-monitor` + - `/usr/bin/moci-speedtest-monitor` + - `/etc/init.d/netify-collector` + - `/etc/init.d/ping-monitor` +- installs `/etc/config/moci` defaults for Monitoring + Netify +- enables/restarts `rpcd`, `uhttpd`, `netify-collector`, `ping-monitor`, `vnstat`, `nlbwmon`, `netifyd` + +After running script, open: +- `http:///moci/` + +If ACLs changed, log out/in once to refresh your ubus session permissions. + + +--- + +## Monitoring & Netify Backends + +MoCI’s Monitoring and Netify tabs are backed by local services running on the router. +The frontend reads data via `ubus` (`file.read`, `file.exec`, `uci.get`) and does not do in-browser probing. + +### Monitoring backend (Ping service) + +**Service and scripts** +- Init script: `files/ping-monitor.init` +- Worker: `files/moci-ping-monitor.sh` +- Runtime command: `/usr/bin/moci-ping-monitor` + +**Data flow** +1. Procd starts `moci-ping-monitor` (if `moci.ping_monitor.enabled=1`). +2. Script loads config from UCI (`moci.ping_monitor.*`). +3. It pings target (default `1.1.1.1`) on interval and appends rows to flat file. +4. Rows are stored in `output_file` (default `/tmp/moci-ping-monitor.txt`) as: + - `timestamp|target|status|latency|message` +5. Monitoring UI reads `/tmp/moci-ping-monitor.txt` and renders: + - status cards + - timeline + - recent samples table + +**Config keys (`/etc/config/moci`)** +- `config ping 'ping_monitor'` +- `option enabled '1'` +- `option target '1.1.1.1'` +- `option interval '60'` +- `option threshold '100'` +- `option timeout '2'` +- `option output_file '/tmp/moci-ping-monitor.txt'` +- `option max_lines '2000'` + +**Service control** +```bash +/etc/init.d/ping-monitor enable +/etc/init.d/ping-monitor start +/etc/init.d/ping-monitor restart +``` + +### Monitoring backend (Daily speedtest service via cron) + +**Worker script** +- Worker: `files/moci-speedtest-monitor.sh` +- Runtime command: `/usr/bin/moci-speedtest-monitor` + +**Data flow** +1. UI stores daily schedule in UCI (`moci.speedtest_monitor.*`). +2. UI applies/removes a managed root cron entry (`# MOCI_SPEEDTEST_MONITOR`). +3. Cron runs `/usr/bin/moci-speedtest-monitor --once` once per day. +4. Script runs `speedtestcpp`, parses download/upload, and appends rows to: + - `/tmp/moci-speedtest-monitor.txt` +5. Monitoring UI reads this file and renders: + - last download/upload cards + - daily up/down line graph + - recent speedtest sample table + +**Row format** +- `timestamp|status|download_mbps|upload_mbps|server|message` + +**Config keys (`/etc/config/moci`)** +- `config speedtest 'speedtest_monitor'` +- `option enabled '1'` +- `option run_hour '3'` +- `option run_minute '15'` +- `option output_file '/tmp/moci-speedtest-monitor.txt'` +- `option max_lines '365'` + +### Netify backend (Collector + SQLite flow store) + +**Service and scripts** +- Init script: `files/netify-collector.init` +- Worker: `files/moci-netify-collector.sh` +- Runtime command: `/usr/bin/moci-netify-collector` + +**Data flow** +1. Procd starts `moci-netify-collector` (if `moci.collector.enabled=1`). +2. Collector reads UCI config (`moci.collector.*`). +3. It connects to Netify stream via netcat (`nc host port`) with inactivity timeout (`stream_timeout`) so stale sockets are reconnected automatically. +4. `type:"flow"` events are inserted into a local SQLite database (`flow_raw` table). +5. Netify UI reads recent rows via `sqlite3` and renders: + - flow/app/device counters + - top applications + - recent flows + - collector and file status + +**Default database file** +- `/tmp/moci-netify.sqlite` + +**Config keys (`/etc/config/moci`)** +- `config netify 'collector'` +- `option enabled '1'` +- `option host '127.0.0.1'` +- `option port '7150'` +- `option db_path '/tmp/moci-netify.sqlite'` +- `option retention_rows '500000'` +- `option stream_timeout '45'` + +**Service control** +```bash +/etc/init.d/netify-collector enable +/etc/init.d/netify-collector start +/etc/init.d/netify-collector restart +``` + +### Local demo mode + +When using the root `index.html` demo wrapper (`/index.html`), Monitoring and Netify data are mocked in-browser: +- ping history/service responses are simulated +- Netify flow data is simulated + +This lets you test UI behavior locally without running router services. + +--- + +## Traffic History (VNSTAT) + +The dashboard card **TRAFFIC HISTORY (VNSTAT)** is powered by `vnstat` JSON output. + +### How it works + +1. Dashboard module runs `file.exec` on: + - `/usr/bin/vnstat --json` (primary) + - `/usr/sbin/vnstat --json` (fallback) +2. It parses `interfaces[].traffic` arrays from JSON. +3. User chooses period from the card: + - `HOURLY` -> `traffic.hour` / `traffic.hours` + - `DAILY` -> `traffic.day` / `traffic.days` + - `MONTHLY` -> `traffic.month` / `traffic.months` +4. UI keeps the **last 12** points for the selected period. +5. It renders grouped bars on canvas: + - Download (`rx`) + - Upload (`tx`) +6. Data refresh is throttled to about once per minute while on dashboard. + +### UI behavior + +- Default period is **HOURLY**. +- Period buttons switch the chart immediately. +- If `vnstat` is unavailable or no data exists, the chart shows a fallback message. +- Card visibility is controlled by feature flag: + - `moci.features.traffic_history` + +### Requirements + +- `vnstat` installed and collecting traffic. +- Optional LuCI integration package: + - `luci-app-vnstat` (or `luci-app-vnstat2` depending on repo). + +### Feature toggle + +```bash +uci set moci.features.traffic_history='1' # show +uci set moci.features.traffic_history='0' # hide +uci commit moci +``` + +--- + +## Security + +Uses OpenWrt's native authentication system. Same security model as LuCI: + +| Feature | MoCI | LuCI | +|---------|------|------| +| Authentication | ubus sessions | ubus sessions | +| Authorization | rpcd ACLs | rpcd ACLs | + +All operations validated server-side. No privilege escalation paths. + +--- + +## Development + +**Auto-deploy on save:** + +```bash +# QEMU VM +pnpm dev + +# Physical router +pnpm dev:physical 192.168.1.1 +``` + +**Project structure:** + +``` +moci/ +├── index.html - Application shell +├── app.css - Styling +└── js/ + ├── core.js - Core functionality + └── modules/ - Feature modules (dashboard, network, system, vpn, services) +``` + +**Adding features:** + +```javascript +const [status, result] = await this.ubusCall('system', 'info', {}); +``` + +--- + +## Browser Support + +Chrome 90+ • Firefox 88+ • Safari 14+ • Any modern browser + +--- + +## License + +MIT diff --git a/files/moci-netify-collector.sh b/files/moci-netify-collector.sh new file mode 100755 index 0000000..79952bf --- /dev/null +++ b/files/moci-netify-collector.sh @@ -0,0 +1,245 @@ +#!/bin/sh + +# MoCI Netify collector for OpenWrt. +# Captures Netify flow JSON events and stores them in a local SQLite database. + +set -e + +DEFAULT_HOST="127.0.0.1" +DEFAULT_PORT="7150" +DEFAULT_DB="/tmp/moci-netify.sqlite" +DEFAULT_RETENTION_ROWS="500000" +DEFAULT_STREAM_TIMEOUT="45" +RECONNECT_DELAY="3" +LOG_FILE="/tmp/moci-netify-collector.log" + +NETIFY_HOST="$DEFAULT_HOST" +NETIFY_PORT="$DEFAULT_PORT" +NETIFY_DB="$DEFAULT_DB" +RETENTION_ROWS="$DEFAULT_RETENTION_ROWS" +STREAM_TIMEOUT="$DEFAULT_STREAM_TIMEOUT" +SQLITE_BIN="" +NETIFY_FEATURE_ENABLED="1" + +log() { + printf "%s %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" +} + +init_logging() { + local dir + dir="$(dirname "$LOG_FILE")" + mkdir -p "$dir" + touch "$LOG_FILE" + exec >>"$LOG_FILE" 2>&1 +} + +sanitize_text() { + local value + value="${1:-}" + value="${value#\'}" + value="${value%\'}" + value="${value#\"}" + value="${value%\"}" + printf "%s" "$value" +} + +sanitize_int() { + case "${1:-}" in + '' | *[!0-9]*) + echo "$2" + ;; + *) + echo "$1" + ;; + esac +} + +find_sqlite_bin() { + if command -v sqlite3 >/dev/null 2>&1; then + SQLITE_BIN="$(command -v sqlite3)" + return 0 + fi + if command -v sqlite3-cli >/dev/null 2>&1; then + SQLITE_BIN="$(command -v sqlite3-cli)" + return 0 + fi + return 1 +} + +sql_exec() { + local query output rc + query="$1" + output="$("$SQLITE_BIN" "$NETIFY_DB" "PRAGMA busy_timeout=3000; $query" 2>&1)" + rc=$? + if [ "$rc" -ne 0 ]; then + log "sqlite error: $output" + return "$rc" + fi + return 0 +} + +load_config() { + if command -v uci >/dev/null 2>&1; then + local value + + value="$(uci -q get moci.collector.host 2>/dev/null || true)" + value="$(sanitize_text "$value")" + [ -n "$value" ] && NETIFY_HOST="$value" + + value="$(uci -q get moci.collector.port 2>/dev/null || true)" + value="$(sanitize_text "$value")" + [ -n "$value" ] && NETIFY_PORT="$value" + + value="$(uci -q get moci.collector.db_path 2>/dev/null || true)" + value="$(sanitize_text "$value")" + [ -n "$value" ] && NETIFY_DB="$value" + + # Backward compatibility with older key. + value="$(uci -q get moci.collector.output_file 2>/dev/null || true)" + value="$(sanitize_text "$value")" + if [ -n "$value" ] && [ "$NETIFY_DB" = "$DEFAULT_DB" ]; then + case "$value" in + *.sqlite | *.sqlite3) + NETIFY_DB="$value" + ;; + esac + fi + + value="$(uci -q get moci.collector.retention_rows 2>/dev/null || true)" + value="$(sanitize_text "$value")" + [ -n "$value" ] && RETENTION_ROWS="$value" + + # Backward compatibility with older key. + value="$(uci -q get moci.collector.max_lines 2>/dev/null || true)" + value="$(sanitize_text "$value")" + if [ -n "$value" ] && [ "$RETENTION_ROWS" = "$DEFAULT_RETENTION_ROWS" ]; then + RETENTION_ROWS="$value" + fi + + value="$(uci -q get moci.collector.stream_timeout 2>/dev/null || true)" + value="$(sanitize_text "$value")" + [ -n "$value" ] && STREAM_TIMEOUT="$value" + + value="$(uci -q get moci.features.netify 2>/dev/null || true)" + value="$(sanitize_text "$value")" + [ -n "$value" ] && NETIFY_FEATURE_ENABLED="$value" + fi +} + +refresh_runtime_config() { + load_config + RETENTION_ROWS="$(sanitize_int "$RETENTION_ROWS" "$DEFAULT_RETENTION_ROWS")" + STREAM_TIMEOUT="$(sanitize_int "$STREAM_TIMEOUT" "$DEFAULT_STREAM_TIMEOUT")" + ensure_db_file +} + +require_dependencies() { + command -v nc >/dev/null 2>&1 || { + log "nc not found; install netcat" + exit 1 + } + find_sqlite_bin || { + log "sqlite3 not found; install sqlite3-cli" + exit 1 + } +} + +ensure_db_file() { + local dir + dir="$(dirname "$NETIFY_DB")" + mkdir -p "$dir" + [ -f "$NETIFY_DB" ] || : >"$NETIFY_DB" + init_db +} + +init_db() { + sql_exec "PRAGMA journal_mode=WAL;" + sql_exec "CREATE TABLE IF NOT EXISTS flow_raw ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timeinsert INTEGER NOT NULL DEFAULT (strftime('%s','now')), + json TEXT NOT NULL + );" + sql_exec "CREATE INDEX IF NOT EXISTS idx_flow_raw_time ON flow_raw(timeinsert);" +} + +prune_db() { + local keep + keep="$(sanitize_int "$RETENTION_ROWS" "$DEFAULT_RETENTION_ROWS")" + sql_exec "DELETE FROM flow_raw + WHERE id <= ( + SELECT CASE + WHEN MAX(id) > $keep THEN MAX(id) - $keep + ELSE 0 + END + FROM flow_raw + );" +} + +is_flow_event() { + echo "$1" | grep -Eq '"type"[[:space:]]*:[[:space:]]*"flow"' +} + +sql_escape() { + printf "%s" "$1" | sed "s/'/''/g" +} + +insert_flow() { + local escaped + escaped="$(sql_escape "$1")" + sql_exec "INSERT INTO flow_raw(timeinsert, json) VALUES (strftime('%s','now'), '$escaped');" +} + +consume_stream() { + local line counter + counter=0 + + nc -w "$STREAM_TIMEOUT" "$NETIFY_HOST" "$NETIFY_PORT" | while IFS= read -r line; do + [ -n "$line" ] || continue + if ! is_flow_event "$line"; then + continue + fi + + insert_flow "$line" || continue + counter=$((counter + 1)) + if [ $((counter % 200)) -eq 0 ]; then + prune_db || true + fi + done +} + +run_forever() { + refresh_runtime_config + if [ "$NETIFY_FEATURE_ENABLED" != "1" ]; then + log "netify feature disabled (moci.features.netify=$NETIFY_FEATURE_ENABLED); exiting collector" + exit 0 + fi + log "starting netify collector host=$NETIFY_HOST port=$NETIFY_PORT db=$NETIFY_DB timeout=${STREAM_TIMEOUT}s" + while true; do + refresh_runtime_config + if [ "$NETIFY_FEATURE_ENABLED" != "1" ]; then + log "netify feature disabled (moci.features.netify=$NETIFY_FEATURE_ENABLED); exiting collector" + exit 0 + fi + log "connecting to netify stream at $NETIFY_HOST:$NETIFY_PORT" + consume_stream || true + log "stream disconnected; retrying in ${RECONNECT_DELAY}s" + sleep "$RECONNECT_DELAY" + done +} + +main() { + init_logging + require_dependencies + refresh_runtime_config + + case "${1:-}" in + --init-db | --init-file) + log "netify sqlite database initialized at $NETIFY_DB" + exit 0 + ;; + esac + + run_forever +} + +main "$@" diff --git a/files/moci-ping-monitor.sh b/files/moci-ping-monitor.sh new file mode 100755 index 0000000..17152c7 --- /dev/null +++ b/files/moci-ping-monitor.sh @@ -0,0 +1,141 @@ +#!/bin/sh + +# MoCI Ping Monitor +# Runs continuously on OpenWrt and writes ping samples to /tmp/moci-ping-monitor.txt + +set -u + +DEFAULT_TARGET="1.1.1.1" +DEFAULT_INTERVAL="60" +DEFAULT_TIMEOUT="2" +DEFAULT_OUTPUT="/tmp/moci-ping-monitor.txt" +DEFAULT_MAX_LINES="2000" + +PING_TARGET="$DEFAULT_TARGET" +PING_INTERVAL="$DEFAULT_INTERVAL" +PING_TIMEOUT="$DEFAULT_TIMEOUT" +PING_OUTPUT="$DEFAULT_OUTPUT" +PING_MAX_LINES="$DEFAULT_MAX_LINES" + +log() { + logger -t moci-ping-monitor "$*" +} + +load_config() { + if command -v uci >/dev/null 2>&1; then + local value + value="$(uci -q get moci.ping_monitor.target 2>/dev/null || true)" + [ -n "$value" ] && PING_TARGET="$value" + + value="$(uci -q get moci.ping_monitor.interval 2>/dev/null || true)" + [ -n "$value" ] && PING_INTERVAL="$value" + + value="$(uci -q get moci.ping_monitor.timeout 2>/dev/null || true)" + [ -n "$value" ] && PING_TIMEOUT="$value" + + value="$(uci -q get moci.ping_monitor.output_file 2>/dev/null || true)" + [ -n "$value" ] && PING_OUTPUT="$value" + + value="$(uci -q get moci.ping_monitor.max_lines 2>/dev/null || true)" + [ -n "$value" ] && PING_MAX_LINES="$value" + fi +} + +refresh_runtime_config() { + load_config + PING_INTERVAL="$(sanitize_int "$PING_INTERVAL" "$DEFAULT_INTERVAL")" + PING_TIMEOUT="$(sanitize_int "$PING_TIMEOUT" "$DEFAULT_TIMEOUT")" + PING_MAX_LINES="$(sanitize_int "$PING_MAX_LINES" "$DEFAULT_MAX_LINES")" + ensure_output_file +} + +sanitize_int() { + case "${1:-}" in + '' | *[!0-9]*) + echo "$2" + ;; + *) + echo "$1" + ;; + esac +} + +ensure_output_file() { + local dir + dir="$(dirname "$PING_OUTPUT")" + mkdir -p "$dir" + [ -f "$PING_OUTPUT" ] || : >"$PING_OUTPUT" +} + +extract_latency() { + local input="$1" + echo "$input" | sed -n 's/.*time[=<]\([0-9.][0-9.]*\).*/\1/p' | head -n 1 +} + +append_sample() { + local ts="$1" + local target="$2" + local status="$3" + local latency="$4" + local msg="$5" + + printf "%s|%s|%s|%s|%s\n" "$ts" "$target" "$status" "$latency" "$msg" >>"$PING_OUTPUT" +} + +prune_file() { + local max_lines + max_lines="$(sanitize_int "$PING_MAX_LINES" "$DEFAULT_MAX_LINES")" + local current + current="$(wc -l <"$PING_OUTPUT" 2>/dev/null || echo 0)" + current="$(sanitize_int "$current" "0")" + + if [ "$current" -gt "$max_lines" ]; then + tail -n "$max_lines" "$PING_OUTPUT" >"${PING_OUTPUT}.tmp" && mv "${PING_OUTPUT}.tmp" "$PING_OUTPUT" + fi +} + +run_ping_once() { + local now output latency status message + now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + output="$(ping -c 1 -W "$PING_TIMEOUT" "$PING_TARGET" 2>&1 || true)" + latency="$(extract_latency "$output")" + + if [ -n "$latency" ]; then + status="OK" + message="reply" + else + status="ERROR" + latency="N/A" + message="$(echo "$output" | tail -n 1 | tr '|' ' ' | tr -s ' ')" + [ -z "$message" ] && message="timeout" + fi + + append_sample "$now" "$PING_TARGET" "$status" "$latency" "$message" + prune_file +} + +run_forever() { + refresh_runtime_config + log "starting target=$PING_TARGET interval=${PING_INTERVAL}s output=$PING_OUTPUT" + while true; do + refresh_runtime_config + run_ping_once + sleep "$PING_INTERVAL" + done +} + +main() { + refresh_runtime_config + + case "${1:-}" in + --once) + run_ping_once + ;; + *) + run_forever + ;; + esac +} + +main "$@" diff --git a/files/moci-speedtest-monitor.sh b/files/moci-speedtest-monitor.sh new file mode 100644 index 0000000..0f8a69b --- /dev/null +++ b/files/moci-speedtest-monitor.sh @@ -0,0 +1,150 @@ +#!/bin/sh + +# MoCI Speedtest Monitor +# Runs speedtestcpp on demand and writes samples to /tmp/moci-speedtest-monitor.txt + +set -u + +DEFAULT_OUTPUT="/tmp/moci-speedtest-monitor.txt" +DEFAULT_MAX_LINES="365" +DEFAULT_BIN="speedtestcpp" + +SPEEDTEST_OUTPUT="$DEFAULT_OUTPUT" +SPEEDTEST_MAX_LINES="$DEFAULT_MAX_LINES" +SPEEDTEST_BIN="$DEFAULT_BIN" + +load_config() { + if command -v uci >/dev/null 2>&1; then + local value + value="$(uci -q get moci.speedtest_monitor.output_file 2>/dev/null || true)" + [ -n "$value" ] && SPEEDTEST_OUTPUT="$value" + + value="$(uci -q get moci.speedtest_monitor.max_lines 2>/dev/null || true)" + [ -n "$value" ] && SPEEDTEST_MAX_LINES="$value" + + value="$(uci -q get moci.speedtest_monitor.bin 2>/dev/null || true)" + [ -n "$value" ] && SPEEDTEST_BIN="$value" + fi +} + +sanitize_int() { + case "${1:-}" in + '' | *[!0-9]*) + echo "$2" + ;; + *) + echo "$1" + ;; + esac +} + +ensure_output_file() { + local dir + dir="$(dirname "$SPEEDTEST_OUTPUT")" + mkdir -p "$dir" + [ -f "$SPEEDTEST_OUTPUT" ] || : >"$SPEEDTEST_OUTPUT" +} + +append_sample() { + local ts="$1" + local status="$2" + local download="$3" + local upload="$4" + local server="$5" + local message="$6" + printf "%s|%s|%s|%s|%s|%s\n" "$ts" "$status" "$download" "$upload" "$server" "$message" >>"$SPEEDTEST_OUTPUT" +} + +prune_file() { + local max_lines current + max_lines="$(sanitize_int "$SPEEDTEST_MAX_LINES" "$DEFAULT_MAX_LINES")" + current="$(wc -l <"$SPEEDTEST_OUTPUT" 2>/dev/null || echo 0)" + current="$(sanitize_int "$current" "0")" + if [ "$current" -gt "$max_lines" ]; then + tail -n "$max_lines" "$SPEEDTEST_OUTPUT" >"${SPEEDTEST_OUTPUT}.tmp" && mv "${SPEEDTEST_OUTPUT}.tmp" "$SPEEDTEST_OUTPUT" + fi +} + +normalize_speed_mbps() { + local value="$1" + if [ -z "$value" ]; then + echo "" + return + fi + # Heuristic: if value is very large, assume bits/sec and convert to Mbps. + awk -v n="$value" 'BEGIN { if (n > 10000) printf "%.2f", n / 1000000; else printf "%.2f", n; }' +} + +extract_json_number() { + local key="$1" + local input="$2" + echo "$input" | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\\([0-9][0-9.]*\\).*/\\1/p" | head -n 1 +} + +extract_text_number() { + local label="$1" + local input="$2" + echo "$input" | sed -n "s/.*$label[^0-9]*\\([0-9][0-9.]*\\).*/\\1/p" | head -n 1 +} + +extract_server() { + local input="$1" + local value + value="$(echo "$input" | sed -n 's/.*"server_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" + [ -n "$value" ] || value="$(echo "$input" | sed -n 's/.*"server"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" + [ -n "$value" ] || value="$(echo "$input" | sed -n 's/.*[Ss]erver[^:]*:[[:space:]]*\([^,]*\).*/\1/p' | head -n 1)" + echo "$value" +} + +run_speedtest_once() { + local now output dl ul dl_norm ul_norm server status message + now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + if ! command -v "$SPEEDTEST_BIN" >/dev/null 2>&1; then + append_sample "$now" "ERROR" "N/A" "N/A" "" "speedtestcpp not found" + prune_file + return 1 + fi + + output="$({ "$SPEEDTEST_BIN" --json 2>/dev/null || "$SPEEDTEST_BIN" 2>&1; } || true)" + dl="$(extract_json_number "download" "$output")" + ul="$(extract_json_number "upload" "$output")" + [ -n "$dl" ] || dl="$(extract_text_number "[Dd]ownload" "$output")" + [ -n "$ul" ] || ul="$(extract_text_number "[Uu]pload" "$output")" + + dl_norm="$(normalize_speed_mbps "$dl")" + ul_norm="$(normalize_speed_mbps "$ul")" + server="$(extract_server "$output" | tr '|' ' ' | tr -s ' ')" + + if [ -n "$dl_norm" ] && [ -n "$ul_norm" ]; then + status="OK" + message="speedtest completed" + else + status="ERROR" + dl_norm="N/A" + ul_norm="N/A" + message="$(echo "$output" | tail -n 1 | tr '|' ' ' | tr -s ' ')" + [ -z "$message" ] && message="speedtest failed" + fi + + append_sample "$now" "$status" "$dl_norm" "$ul_norm" "$server" "$message" + prune_file + [ "$status" = "OK" ] +} + +main() { + load_config + SPEEDTEST_MAX_LINES="$(sanitize_int "$SPEEDTEST_MAX_LINES" "$DEFAULT_MAX_LINES")" + ensure_output_file + + case "${1:-}" in + --init-file) + : >"$SPEEDTEST_OUTPUT" + ;; + --once | *) + run_speedtest_once + ;; + esac +} + +main "$@" diff --git a/files/moci.config b/files/moci.config index ee0d2d2..5f22c72 100644 --- a/files/moci.config +++ b/files/moci.config @@ -1,10 +1,17 @@ config ui 'features' option dashboard '1' + option devices '1' option network '1' + option traffic_history '1' + option monitoring '1' + option netify '1' + option show_lan_ip '0' + option colorful_graphs '0' option wireless '1' option firewall '1' option dhcp '1' option dns '1' + option adblock '1' option wireguard '1' option qos '1' option ddns '1' @@ -34,15 +41,21 @@ config preset 'ap_switch' option wireguard '0' option ddns '0' option dns '0' + option adblock '0' config preset 'minimal' option name 'Minimal' option description 'Dashboard and system settings only' + option devices '0' option network '0' + option traffic_history '0' + option monitoring '0' + option netify '0' option wireless '0' option firewall '0' option dhcp '0' option dns '0' + option adblock '0' option wireguard '0' option qos '0' option ddns '0' @@ -54,3 +67,27 @@ config preset 'minimal' option storage '0' option leds '0' option firmware '0' + +config netify 'collector' + option enabled '1' + option host '127.0.0.1' + option port '7150' + option db_path '/tmp/moci-netify.sqlite' + option retention_rows '500000' + option stream_timeout '45' + +config ping 'ping_monitor' + option enabled '1' + option target '1.1.1.1' + option interval '60' + option threshold '100' + option timeout '2' + option output_file '/tmp/moci-ping-monitor.txt' + option max_lines '2000' + +config speedtest 'speedtest_monitor' + option enabled '1' + option run_hour '3' + option run_minute '15' + option output_file '/tmp/moci-speedtest-monitor.txt' + option max_lines '365' diff --git a/files/netify-collector.init b/files/netify-collector.init new file mode 100755 index 0000000..dd4ef03 --- /dev/null +++ b/files/netify-collector.init @@ -0,0 +1,24 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +PROG="/usr/bin/moci-netify-collector" + +start_service() { + local enabled + enabled="$(uci -q get moci.collector.enabled 2>/dev/null || echo 1)" + [ "$enabled" = "0" ] && return 0 + + procd_open_instance + procd_set_param command "$PROG" + procd_set_param respawn 3600 5 5 + procd_set_param stdout 0 + procd_set_param stderr 0 + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger "moci" +} diff --git a/files/ping-monitor.init b/files/ping-monitor.init new file mode 100755 index 0000000..bece8ec --- /dev/null +++ b/files/ping-monitor.init @@ -0,0 +1,24 @@ +#!/bin/sh /etc/rc.common + +START=96 +STOP=10 +USE_PROCD=1 + +PROG="/usr/bin/moci-ping-monitor" + +start_service() { + local enabled + enabled="$(uci -q get moci.ping_monitor.enabled 2>/dev/null || echo 1)" + [ "$enabled" = "0" ] && return 0 + + procd_open_instance + procd_set_param command "$PROG" + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger "moci" +} diff --git a/index.html b/index.html index 9cba9c6..8b1d314 100644 --- a/index.html +++ b/index.html @@ -84,8 +84,79 @@ syslog: `Jan 25 12:34:56 OpenWrt daemon.info dnsmasq[1234]: started, version 2.86 Jan 25 12:35:12 OpenWrt daemon.info dnsmasq-dhcp[1234]: DHCP, IP range 192.168.1.100 -- 192.168.1.200 Jan 25 12:36:45 OpenWrt kern.info kernel: [12345.678] ath9k: Atheros AR9344 rev 2 -Jan 25 12:37:22 OpenWrt daemon.notice netifd: Interface 'wan' is now up` +Jan 25 12:37:22 OpenWrt daemon.notice netifd: Interface 'wan' is now up`, + vnstat: { + interfaces: [ + { + name: 'br-lan', + traffic: { + hour: [ + { date: { year: 2026, month: 3, day: 16, hour: 13 }, rx: 2147483648, tx: 536870912 }, + { date: { year: 2026, month: 3, day: 16, hour: 14 }, rx: 1932735283, tx: 515396075 }, + { date: { year: 2026, month: 3, day: 16, hour: 15 }, rx: 2415919104, tx: 671088640 }, + { date: { year: 2026, month: 3, day: 16, hour: 16 }, rx: 2684354560, tx: 805306368 }, + { date: { year: 2026, month: 3, day: 16, hour: 17 }, rx: 2254857830, tx: 644245094 }, + { date: { year: 2026, month: 3, day: 16, hour: 18 }, rx: 1825361101, tx: 483183820 }, + { date: { year: 2026, month: 3, day: 16, hour: 19 }, rx: 2791728742, tx: 751619276 }, + { date: { year: 2026, month: 3, day: 16, hour: 20 }, rx: 3113851289, tx: 858993459 }, + { date: { year: 2026, month: 3, day: 16, hour: 21 }, rx: 2576980377, tx: 697932186 }, + { date: { year: 2026, month: 3, day: 16, hour: 22 }, rx: 2040109465, tx: 590558003 }, + { date: { year: 2026, month: 3, day: 16, hour: 23 }, rx: 1503238553, tx: 429496729 }, + { date: { year: 2026, month: 3, day: 17, hour: 0 }, rx: 1181116006, tx: 322122547 } + ], + day: [ + { date: { year: 2026, month: 3, day: 6 }, rx: 26575110144, tx: 6442450944 }, + { date: { year: 2026, month: 3, day: 7 }, rx: 28991029248, tx: 7516192768 }, + { date: { year: 2026, month: 3, day: 8 }, rx: 31138512896, tx: 8589934592 }, + { date: { year: 2026, month: 3, day: 9 }, rx: 25769803776, tx: 6979321856 }, + { date: { year: 2026, month: 3, day: 10 }, rx: 30064771072, tx: 8053063680 }, + { date: { year: 2026, month: 3, day: 11 }, rx: 33285996544, tx: 9126805504 }, + { date: { year: 2026, month: 3, day: 12 }, rx: 34359738368, tx: 9663676416 }, + { date: { year: 2026, month: 3, day: 13 }, rx: 32212254720, tx: 8589934592 }, + { date: { year: 2026, month: 3, day: 14 }, rx: 28991029248, tx: 7516192768 }, + { date: { year: 2026, month: 3, day: 15 }, rx: 25769803776, tx: 6979321856 }, + { date: { year: 2026, month: 3, day: 16 }, rx: 33285996544, tx: 9126805504 }, + { date: { year: 2026, month: 3, day: 17 }, rx: 17179869184, tx: 4294967296 } + ], + month: [ + { date: { year: 2025, month: 5 }, rx: 90324529152, tx: 20253343744 }, + { date: { year: 2025, month: 6 }, rx: 100235010048, tx: 24395415552 }, + { date: { year: 2025, month: 7 }, rx: 118422104064, tx: 28689039360 }, + { date: { year: 2025, month: 8 }, rx: 107468005376, tx: 26508009472 }, + { date: { year: 2025, month: 9 }, rx: 98045300736, tx: 23173550080 }, + { date: { year: 2025, month: 10 }, rx: 111669149696, tx: 29283041280 }, + { date: { year: 2025, month: 11 }, rx: 124339822592, tx: 31584817152 }, + { date: { year: 2025, month: 12 }, rx: 132409196544, tx: 34626039808 }, + { date: { year: 2026, month: 1 }, rx: 127763488768, tx: 32644739072 }, + { date: { year: 2026, month: 2 }, rx: 121482149888, tx: 29812948992 }, + { date: { year: 2026, month: 3 }, rx: 84506664960, tx: 21479002112 } + ] + } + } + ] + }, + pingHistory: `2026-03-20T09:00:00.000Z|1.1.1.1|OK|12.4|icmp_seq=1 ttl=58 +2026-03-20T09:01:00.000Z|1.1.1.1|OK|11.8|icmp_seq=1 ttl=58 +2026-03-20T09:02:00.000Z|1.1.1.1|OK|19.3|icmp_seq=1 ttl=58 +2026-03-20T09:03:00.000Z|1.1.1.1|OK|88.1|icmp_seq=1 ttl=58 +2026-03-20T09:04:00.000Z|1.1.1.1|OK|140.2|icmp_seq=1 ttl=58 +2026-03-20T09:05:00.000Z|1.1.1.1|ERR|N/A|timeout +2026-03-20T09:06:00.000Z|1.1.1.1|OK|23.7|icmp_seq=1 ttl=58 +2026-03-20T09:07:00.000Z|1.1.1.1|OK|17.0|icmp_seq=1 ttl=58 +2026-03-20T09:08:00.000Z|1.1.1.1|OK|14.5|icmp_seq=1 ttl=58 +2026-03-20T09:09:00.000Z|1.1.1.1|OK|13.2|icmp_seq=1 ttl=58 +`, + netifyFlowLog: `{"type":"flow","interface":"eth1","internal":false,"flow":{"last_seen_at":1774101189000,"local_mac":"00:11:22:33:44:55","detected_application_name":"Cloudflare DNS","detected_protocol_name":"DNS","other_ip":"1.1.1.1","other_port":53}} +{"type":"flow","interface":"eth1","internal":false,"flow":{"last_seen_at":1774101184000,"local_mac":"00:11:22:33:44:66","detected_application_name":"YouTube","detected_protocol_name":"HTTP/S","other_ip":"142.250.68.14","other_port":443}} +{"type":"flow","interface":"eth1","internal":false,"flow":{"last_seen_at":1774101180000,"local_mac":"00:11:22:33:44:77","detected_application_name":"Netflix","detected_protocol_name":"HTTP/S","other_ip":"52.4.120.22","other_port":443}} +{"type":"flow","interface":"eth1","internal":false,"flow":{"last_seen_at":1774101176000,"local_mac":"00:11:22:33:44:55","detected_application_name":"OpenAI","detected_protocol_name":"HTTP/S","other_ip":"104.18.33.45","other_port":443}} +{"type":"flow","interface":"eth1","internal":false,"flow":{"last_seen_at":1774101171000,"local_mac":"00:11:22:33:44:66","detected_application_name":"Discord","detected_protocol_name":"HTTP/S","other_ip":"162.159.130.234","other_port":443}} +{"type":"flow","interface":"eth1","internal":false,"flow":{"last_seen_at":1774101166000,"local_mac":"00:11:22:33:44:77","detected_application_name":"NTP","detected_protocol_name":"NTP","other_ip":"129.6.15.28","other_port":123}} +` }; + let pingServiceRunning = true; + let netifyServiceRunning = true; + let netifyFilePresent = true; const originalFetch = win.fetch.bind(win); win.fetch = async (url, options) => { @@ -118,10 +189,77 @@ result = [0, { data: mockData.arpTable }]; } else if (params.path === '/usr/lib/opkg/status') { result = [0, { data: mockData.packages }]; + } else if (params.path === '/tmp/pingTest.txt') { + result = [0, { data: mockData.pingHistory }]; + } else if (params.path === '/tmp/moci-netify-flow.jsonl') { + result = [0, { data: mockData.netifyFlowLog }]; + } + } else if (object === 'file' && method === 'write') { + if (params.path === '/tmp/pingTest.txt') { + mockData.pingHistory = params.data || ''; + result = [0, {}]; } } else if (object === 'file' && method === 'exec') { if (params.command === '/usr/libexec/syslog-wrapper' && Array.isArray(params.params)) { result = [0, { stdout: mockData.syslog }]; + } else if ( + (params.command === '/usr/bin/vnstat' || params.command === '/usr/sbin/vnstat') && + Array.isArray(params.params) && + params.params.includes('--json') + ) { + result = [0, { stdout: JSON.stringify(mockData.vnstat) }]; + } else if ( + params.command === '/usr/libexec/nlbwmon-action' && + Array.isArray(params.params) && + params.params[0] === 'download' + ) { + result = [ + 0, + { + stdout: JSON.stringify({ + columns: ['family', 'mac', 'ip', 'layer7', 'rx_bytes', 'tx_bytes'], + data: [ + ['ipv4', '00:11:22:33:44:55', '192.168.1.100', 'https', 324000000, 90500000], + ['ipv4', '00:11:22:33:44:66', '192.168.1.101', 'youtube', 760000000, 221000000], + ['ipv4', '00:11:22:33:44:77', '192.168.1.102', 'dns', 92000000, 42000000] + ] + }) + } + ]; + } else if (params.command === '/bin/sh' && Array.isArray(params.params) && params.params[0] === '-c') { + const cmd = params.params[1] || ''; + if (cmd.includes('pgrep -f moci-ping-monitor')) { + result = [0, { stdout: pingServiceRunning ? 'RUNNING\n' : 'STOPPED\n' }]; + } else if (cmd.includes('pgrep -f moci-netify-collector')) { + result = [0, { stdout: netifyServiceRunning ? 'RUNNING\n' : 'STOPPED\n' }]; + } else if (cmd.includes('[ -f') && cmd.includes('moci-netify-flow.jsonl')) { + result = [0, { stdout: netifyFilePresent ? 'PRESENT\n' : 'MISSING\n' }]; + } + } else if (params.command === '/etc/init.d/ping-monitor' && Array.isArray(params.params)) { + const action = params.params[0]; + if (action === 'start') pingServiceRunning = true; + if (action === 'stop') pingServiceRunning = false; + if (action === 'restart') pingServiceRunning = true; + result = [0, { stdout: '' }]; + } else if (params.command === '/usr/bin/moci-ping-monitor' && Array.isArray(params.params)) { + const now = new Date().toISOString(); + const newLine = `${now}|1.1.1.1|OK|15.6|icmp_seq=1 ttl=58\n`; + mockData.pingHistory = (mockData.pingHistory || '') + newLine; + result = [0, { stdout: newLine }]; + } else if (params.command === '/etc/init.d/netify-collector' && Array.isArray(params.params)) { + const action = params.params[0]; + if (action === 'start') netifyServiceRunning = true; + if (action === 'stop') netifyServiceRunning = false; + if (action === 'restart') netifyServiceRunning = true; + result = [0, { stdout: '' }]; + } else if (params.command === '/usr/bin/moci-netify-collector' && Array.isArray(params.params)) { + if (params.params.includes('--init-db') || params.params.includes('--init-file')) { + netifyFilePresent = true; + if (!mockData.netifyFlowLog) { + mockData.netifyFlowLog = ''; + } + result = [0, { stdout: 'initialized\n' }]; + } } } else if (object === 'uci' && method === 'get') { if (params.config === 'moci' && params.section === 'features') { @@ -130,7 +268,11 @@ { values: { dashboard: '1', + devices: '1', network: '1', + monitoring: '1', + netify: '1', + show_lan_ip: '0', system: '1', wireless: '1', firewall: '1', @@ -150,7 +292,33 @@ } } ]; + } else if (params.config === 'moci' && params.section === 'ping_monitor') { + result = [ + 0, + { + values: { + target: '1.1.1.1', + interval: '60', + output_file: '/tmp/pingTest.txt' + } + } + ]; + } else if (params.config === 'moci' && params.section === 'collector') { + result = [ + 0, + { + values: { + output_file: '/tmp/moci-netify-flow.jsonl', + max_lines: '5000', + enabled: '1', + host: '127.0.0.1', + port: '7150' + } + } + ]; } + } else if (object === 'uci' && (method === 'set' || method === 'commit' || method === 'add')) { + result = [0, {}]; } return { diff --git a/moci/app.css b/moci/app.css index 06baddf..f4a4bb0 100644 --- a/moci/app.css +++ b/moci/app.css @@ -312,6 +312,10 @@ body { transition: width 0.3s ease; } +.conntrack-progress-fill { + background: linear-gradient(90deg, rgba(124, 198, 255, 0.9), rgba(124, 228, 255, 0.75)); +} + .badge { display: inline-block; padding: 4px 8px; @@ -347,6 +351,36 @@ body { border: 1px solid rgba(226, 226, 229, 0.3); } +.badge-online-soft { + background: rgba(255, 255, 255, 0.02); + color: #bfecc0; + border: 1px solid rgba(144, 238, 144, 0.25); +} + +.badge-offline-soft { + background: rgba(255, 255, 255, 0.02); + color: #d3bf7c; + border: 1px solid rgba(180, 155, 72, 0.32); +} + +.monitoring-excellent-soft { + background: rgba(255, 255, 255, 0.02); + color: #bfecc0; + border: 1px solid rgba(144, 238, 144, 0.25); +} + +.monitoring-latency-soft { + background: rgba(255, 255, 255, 0.02); + color: #d3bf7c; + border: 1px solid rgba(180, 155, 72, 0.32); +} + +.monitoring-high-latency-soft { + background: rgba(255, 170, 170, 0.08); + color: #ffb0b0; + border: 1px solid rgba(255, 125, 125, 0.45); +} + .data-table { width: 100%; border-collapse: collapse; @@ -375,6 +409,24 @@ body { color: var(--starship-steel); } +#devices-table th, +#devices-table td { + padding-left: 12px; + padding-right: 12px; +} + +.devices-row-expandable { + cursor: pointer; +} + +.devices-row-expandable:hover { + background: rgba(255, 255, 255, 0.03); +} + +.devices-netify-detail-row td { + background: rgba(255, 255, 255, 0.015); +} + .data-table tbody tr:hover { background: rgba(255, 255, 255, 0.02); } @@ -448,6 +500,34 @@ body { box-shadow: 0 0 8px rgba(255, 51, 51, 0.15); } +.action-btn-sm.danger { + border-color: rgba(255, 68, 68, 0.35); +} + +.action-btn-sm.success { + border-color: rgba(144, 238, 144, 0.35); + color: #bfecc0; +} + +.action-btn-sm.success:hover { + border-color: rgba(144, 238, 144, 0.65); + color: #c9f2ca; + box-shadow: 0 0 8px rgba(144, 238, 144, 0.15); +} + +.devices-action-btn { + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.05) !important; + border: 1px solid var(--glass-border) !important; + color: var(--starship-steel) !important; + font-family: var(--font-mono); + font-size: 9px; + line-height: 1.2; + padding: 6px 10px; + margin-right: 0; +} + .section-title { font-family: var(--font-mono); font-size: 12px; @@ -689,6 +769,12 @@ body { margin-right: 4px; } +.data-table button.action-link { + background: rgba(255, 255, 255, 0.05); + font-family: var(--font-mono); + cursor: pointer; +} + .data-table .action-link:hover { background: rgba(255, 255, 255, 0.05); border-color: var(--starship-steel); @@ -706,11 +792,55 @@ body { margin-right: 4px; } +.data-table button.action-link-danger { + background: rgba(255, 68, 68, 0.08); + font-family: var(--font-mono); + cursor: pointer; +} + .data-table .action-link-danger:hover { background: rgba(255, 68, 68, 0.1); border-color: #ff4444; } +.action-buttons { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn-icon { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--glass-border); + border-radius: 4px; + background: rgba(255, 255, 255, 0.05); + color: var(--starship-steel); + cursor: pointer; + transition: all 0.2s; + padding: 0; +} + +.btn-icon svg { + width: 14px; + height: 14px; +} + +.btn-icon:hover { + background: rgba(255, 255, 255, 0.1); + border-color: var(--starship-steel); + box-shadow: 0 0 8px rgba(226, 226, 229, 0.2); +} + +.btn-icon.btn-delete:hover { + border-color: var(--alert-red); + color: var(--alert-red); + box-shadow: 0 0 8px rgba(255, 51, 51, 0.2); +} + .modal { position: fixed; top: 0; @@ -1068,6 +1198,54 @@ select.form-input { color: var(--starship-steel); } +.dashboard-status-slim { + padding: 14px 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 30px; +} + +.dashboard-status-slim .hero-status { + margin-bottom: 0; + gap: 12px; +} + +.dashboard-status-slim .hero-indicator { + font-size: 20px; +} + +.dashboard-status-slim .hero-label { + font-size: 10px; + margin-bottom: 2px; +} + +.dashboard-status-slim .hero-value { + font-size: 18px; + line-height: 1.1; +} + +.dashboard-status-slim .hero-details { + margin-left: auto; + padding: 8px 10px; + gap: 18px; + flex-wrap: wrap; + justify-content: flex-end; + background: rgba(255, 255, 255, 0.02); +} + +.dashboard-status-slim .hero-detail { + gap: 4px; +} + +.dashboard-status-slim .hero-detail-label { + font-size: 9px; +} + +.dashboard-status-slim .hero-detail-value { + font-size: 13px; +} + .metric-card { display: flex; align-items: center; @@ -1111,6 +1289,96 @@ select.form-input { .bandwidth-graph-container canvas { width: 100%; height: 100%; + cursor: crosshair; +} + +.bandwidth-tooltip { + position: absolute; + z-index: 4; + min-width: 160px; + max-width: 220px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(14, 14, 16, 0.95); + color: var(--steel-light); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.35; + pointer-events: none; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); +} + +.bandwidth-tooltip-title { + font-weight: 700; + color: var(--starship-steel); + margin-bottom: 4px; +} + +.traffic-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.monthly-traffic-meta { + margin: 0 16px 12px 16px; + font-size: 11px; + color: var(--steel-muted); + font-family: var(--font-mono); +} + +.traffic-period-switch { + display: flex; + gap: 8px; + padding: 0; + flex-wrap: wrap; +} + +.traffic-period-switch .action-btn-sm { + margin-right: 0; +} + +.traffic-period-switch .action-btn-sm.is-active { + background: rgba(255, 255, 255, 0.14); + border-color: var(--starship-steel); + color: var(--starship-steel); +} + +.monthly-traffic-graph-container { + height: 220px; + padding: 16px; + position: relative; +} + +.monthly-traffic-graph-container canvas { + width: 100%; + height: 100%; +} + +.monthly-traffic-tooltip { + position: absolute; + z-index: 4; + min-width: 170px; + max-width: 240px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(14, 14, 16, 0.95); + color: var(--steel-light); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.35; + pointer-events: none; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); +} + +.monthly-traffic-tooltip-title { + font-weight: 700; + color: var(--starship-steel); + margin-bottom: 4px; } .bandwidth-legend { @@ -1135,6 +1403,14 @@ select.form-input { border-radius: 2px; } +.legend-download { + background: rgba(226, 226, 229, 0.9); +} + +.legend-upload { + background: rgba(226, 226, 229, 0.5); +} + .loading-skeleton { position: relative; overflow: hidden; @@ -1166,227 +1442,221 @@ select.form-input { opacity: 0.3; } -.metrics-3col { - grid-template-columns: repeat(3, 1fr); +.monitoring-controls { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 16px; } -.menu-toggle { - display: none; - flex-direction: column; - justify-content: center; - gap: 5px; - background: none; - border: none; - cursor: pointer; - padding: 8px; - margin-right: 8px; +.monitoring-metrics-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 24px; } -.menu-toggle span { - display: block; - width: 20px; - height: 2px; - background: var(--starship-steel); - transition: all 0.2s; +.monitoring-speedtest-metrics-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 14px; } -@media (max-width: 1024px) { - .content { - padding: 24px; - } - - .hero-details { - gap: 24px; - } - - .stats-grid { - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - } +.monitoring-timeline-wrap { + margin-top: 12px; } -@media (max-width: 768px) { - .menu-toggle { - display: flex; - } - - .nav { - position: fixed; - top: 64px; - left: 0; - right: 0; - flex-direction: column; - gap: 0; - margin-left: 0; - background: var(--glass-bg); - backdrop-filter: blur(16px); - border-bottom: 1px solid var(--glass-border); - box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5); - display: none; - z-index: 200; - } - - .nav.open { - display: flex; - } - - .nav a { - padding: 16px 32px; - border-bottom: none; - border-left: 2px solid transparent; - } - - .nav a:hover, - .nav a.active { - border-bottom: none; - border-left-color: var(--starship-steel); - background: rgba(255, 255, 255, 0.03); - } - - .content { - padding: 16px; - } - - .content h2 { - margin-bottom: 20px; - } +.monitoring-timeline-bars { + display: flex; + gap: 2px; + height: 16px; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.06); +} - .tabs { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - } +.monitoring-segment { + flex: 1; + height: 100%; + min-width: 6px; +} - .tabs::-webkit-scrollbar { - display: none; - } +.monitoring-segment.seg-ok { + background: #22c55e; +} - .tab-btn { - flex-shrink: 0; - padding: 10px 14px; - font-size: 10px; - } +.monitoring-segment.seg-good { + background: #15803d; +} - .hero-details { - flex-wrap: wrap; - gap: 16px; - } +.monitoring-segment.seg-warn { + background: #f59e0b; +} - .hero-value { - font-size: 22px; - } +.monitoring-segment.seg-error { + background: #ef4444; +} - .metric-value { - font-size: 20px; - } +.monitoring-timeline-labels { + display: flex; + justify-content: space-between; + gap: 4px; + margin-top: 8px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--steel-muted); +} - .stat-value { - font-size: 18px; - } +.monitoring-empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-family: var(--font-mono); + font-size: 11px; + color: var(--steel-muted); +} - .stats-grid, - .metrics-3col { - grid-template-columns: 1fr; - } +.monitoring-speedtest-controls { + display: grid; + grid-template-columns: minmax(240px, 320px) auto; + gap: 10px; + align-items: center; + justify-content: start; +} - .stat-card { - overflow-x: auto; - } +.monitoring-speedtest-toggle-actions { + justify-content: flex-start; + margin-top: 0; + margin-bottom: 0; + gap: 8px; +} - .data-table { - min-width: 500px; - } +.monitoring-speedtest-chart-wrap { + margin-top: 14px; +} - .quick-actions { - flex-wrap: wrap; - } +.monitoring-speedtest-chart { + display: block; + width: 100%; + height: 240px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border); +} - .toast { - right: 16px; - left: 16px; - min-width: unset; - } +.monitoring-speedtest-grid { + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 1; +} - .login-card { - min-width: unset; - width: 90%; - padding: 32px; - } +.monitoring-speedtest-line-download { + fill: none; + stroke: rgba(56, 189, 248, 0.95); + stroke-width: 2.5; +} - .modal-content { - width: 95%; - max-height: 95vh; - } +.monitoring-speedtest-line-upload { + fill: none; + stroke: rgba(248, 153, 56, 0.95); + stroke-width: 2.5; +} - .diagnostic-form { - flex-direction: column; - } +.monitoring-speedtest-point-download { + fill: rgba(56, 189, 248, 1); } -@media (max-width: 480px) { - .content { - padding: 12px; - } +.monitoring-speedtest-point-upload { + fill: rgba(248, 153, 56, 1); +} - .header { - padding: 0 16px; - } +.monitoring-speedtest-legend { + font-family: var(--font-mono); + font-size: 11px; + fill: var(--steel-muted); +} - .logo span { - display: none; - } +.monitoring-speedtest-labels { + display: flex; + justify-content: space-between; + gap: 4px; + margin-top: 8px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--steel-muted); +} - .hero-card { - padding: 20px; - } +.netify-fqdn-ellipsis { + display: inline-block; + max-width: 250px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; +} - .hero-value { - font-size: 18px; - } +.software-root-space-fill { + background: linear-gradient(90deg, rgba(90, 180, 255, 0.95), rgba(120, 215, 255, 0.88)); +} - .hero-detail-value { - font-size: 13px; - } +.netify-collapsible-header { + display: flex; + align-items: center; + justify-content: space-between; +} - .stat-card { - padding: 16px; - } +.netify-toggle-btn { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.04); + color: var(--starship-steel); + border-radius: 6px; + cursor: pointer; + font-size: 14px; + line-height: 1; +} - .metric-icon { - font-size: 32px; - } +.netify-toggle-btn:hover { + background: rgba(255, 255, 255, 0.08); +} - .metric-card { - gap: 12px; - padding: 16px; +@media (max-width: 960px) { + .monitoring-controls { + grid-template-columns: 1fr; } - .login-card { - padding: 24px; + .monitoring-speedtest-controls { + grid-template-columns: 1fr; } - .content h2 { - font-size: 12px; + .monitoring-speedtest-toggle-actions { + justify-content: flex-start; } - .section-title { - font-size: 11px; + .monitoring-metrics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .logout-btn { - padding: 6px 10px; - font-size: 9px; + .monitoring-speedtest-metrics-grid { + grid-template-columns: 1fr; } - .modal-header { - padding: 16px; + .dashboard-status-slim { + flex-direction: column; + align-items: flex-start; } - .modal-body { - padding: 16px; + .dashboard-status-slim .hero-details { + width: 100%; + justify-content: space-between; } +} - .modal-footer { - padding: 16px; - flex-wrap: wrap; +@media (max-width: 640px) { + .monitoring-metrics-grid { + grid-template-columns: 1fr; } } + +/* cache bust */ diff --git a/moci/app.js b/moci/app.js new file mode 100644 index 0000000..4ec2874 --- /dev/null +++ b/moci/app.js @@ -0,0 +1,3944 @@ +class OpenWrtApp { + constructor() { + this.sessionId = localStorage.getItem('ubus_session'); + this.pollInterval = null; + this.loadHistory = []; + this.bandwidthHistory = { down: [], up: [] }; + this.lastNetStats = null; + this.lastCpuStats = null; + this.bandwidthCanvas = null; + this.bandwidthCtx = null; + this.init(); + } + + async init() { + if (this.sessionId) { + const valid = await this.validateSession(); + if (valid) { + this.showMainView(); + this.loadDashboard(); + this.startPolling(); + } else { + const savedCreds = this.getSavedCredentials(); + if (savedCreds) { + await this.autoLogin(savedCreds.username, savedCreds.password); + } else { + this.showLoginView(); + } + } + } else { + const savedCreds = this.getSavedCredentials(); + if (savedCreds) { + await this.autoLogin(savedCreds.username, savedCreds.password); + } else { + this.showLoginView(); + } + } + this.attachEventListeners(); + } + + getSavedCredentials() { + try { + const saved = localStorage.getItem('saved_credentials'); + return saved ? JSON.parse(atob(saved)) : null; + } catch { + return null; + } + } + + saveCredentials(username, password) { + const creds = btoa(JSON.stringify({ username, password })); + localStorage.setItem('saved_credentials', creds); + } + + clearSavedCredentials() { + localStorage.removeItem('saved_credentials'); + } + + async autoLogin(username, password) { + try { + await this.login(username, password, true); + } catch { + this.clearSavedCredentials(); + this.showLoginView(); + } + } + + async ubusCall(object, method, params = {}) { + const response = await fetch('/ubus', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: Math.random(), + method: 'call', + params: [this.sessionId || '00000000000000000000000000000000', object, method, params] + }) + }); + + const data = await response.json(); + if (data.error) throw new Error(data.error.message); + return data.result; + } + + openModal(modalId) { + document.getElementById(modalId).classList.remove('hidden'); + } + + closeModal(modalId) { + document.getElementById(modalId).classList.add('hidden'); + } + + setupModal(modalId, openBtnId, closeBtnId, cancelBtnId, saveBtnId, saveHandler) { + if (openBtnId) { + document.getElementById(openBtnId).addEventListener('click', () => this.openModal(modalId)); + } + if (closeBtnId) { + document.getElementById(closeBtnId).addEventListener('click', () => this.closeModal(modalId)); + } + if (cancelBtnId) { + document.getElementById(cancelBtnId).addEventListener('click', () => this.closeModal(modalId)); + } + if (saveBtnId && saveHandler) { + document.getElementById(saveBtnId).addEventListener('click', saveHandler); + } + } + + async uciGet(config, section = null) { + const params = { config }; + if (section) params.section = section; + return await this.ubusCall('uci', 'get', params); + } + + async uciSet(config, section, values) { + await this.ubusCall('uci', 'set', { config, section, values }); + } + + async uciAdd(config, type, name, values) { + await this.ubusCall('uci', 'add', { config, type, name, values }); + } + + async uciDelete(config, section) { + await this.ubusCall('uci', 'delete', { config, section }); + } + + async uciCommit(config) { + await this.ubusCall('uci', 'commit', { config }); + } + + async serviceReload(service) { + await this.ubusCall('file', 'exec', { + command: `/etc/init.d/${service}`, + params: ['reload'] + }); + } + + renderEmptyTable(tbody, colspan, message) { + tbody.innerHTML = `${message}`; + } + + renderBadge(type, text) { + return `${text}`; + } + + renderStatusBadge(condition, trueText = 'ENABLED', falseText = 'DISABLED') { + return condition ? this.renderBadge('success', trueText) : this.renderBadge('error', falseText); + } + + renderActionButtons(editFn, deleteFn, id) { + return ` + + + `; + } + + getFormValue(id) { + const el = document.getElementById(id); + return el ? el.value.trim() : ''; + } + + async saveUciConfig({ + config, + section, + values, + service, + modal, + successMsg, + reload, + isAdd = false, + addType = 'rule' + }) { + try { + if (isAdd) { + await this.uciAdd(config, addType, section || `cfg_${addType}_${Date.now()}`, values); + } else { + await this.uciSet(config, section, values); + } + + await this.uciCommit(config); + + if (service) { + await this.serviceReload(service); + } + + if (modal) { + this.closeModal(modal); + } + + this.showToast('Success', successMsg, 'success'); + + if (reload) { + await reload(); + } + } catch (err) { + console.error(`Failed to save ${config}:`, err); + this.showToast('Error', `Failed to save ${successMsg.toLowerCase()}`, 'error'); + } + } + + async login(username, password, isAutoLogin = false) { + try { + const result = await fetch('/ubus', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'call', + params: [ + '00000000000000000000000000000000', + 'session', + 'login', + { + username, + password + } + ] + }) + }).then(r => r.json()); + + if (result.result && result.result[1] && result.result[1].ubus_rpc_session) { + this.sessionId = result.result[1].ubus_rpc_session; + localStorage.setItem('ubus_session', this.sessionId); + + if (!isAutoLogin) { + const rememberMe = document.getElementById('remember-me'); + if (rememberMe && rememberMe.checked) { + this.saveCredentials(username, password); + } else { + this.clearSavedCredentials(); + } + } + + return true; + } + return false; + } catch (err) { + console.error('Login error:', err); + return false; + } + } + + async validateSession() { + try { + await this.ubusCall('session', 'access', {}); + return true; + } catch { + return false; + } + } + + async logout() { + try { + await this.ubusCall('session', 'destroy', {}); + } catch {} + localStorage.removeItem('ubus_session'); + this.clearSavedCredentials(); + this.sessionId = null; + this.stopPolling(); + this.showLoginView(); + } + + async loadDashboard() { + try { + const [status, systemInfo] = await this.ubusCall('system', 'info', {}); + const [boardStatus, boardInfo] = await this.ubusCall('system', 'board', {}); + + const hostnameEl = document.getElementById('hostname'); + const uptimeEl = document.getElementById('uptime'); + const memoryEl = document.getElementById('memory'); + const memoryBarEl = document.getElementById('memory-bar'); + + if (hostnameEl) hostnameEl.textContent = boardInfo.hostname || 'OpenWrt'; + if (uptimeEl) uptimeEl.textContent = this.formatUptime(systemInfo.uptime); + + const memPercent = ( + ((systemInfo.memory.total - systemInfo.memory.free) / systemInfo.memory.total) * + 100 + ).toFixed(0); + if (memoryEl) memoryEl.textContent = this.formatMemory(systemInfo.memory); + if (memoryBarEl) memoryBarEl.style.width = memPercent + '%'; + + await this.updateCpuUsage(); + await this.updateNetworkStats(); + await this.updateWANStatus(); + await this.updateSystemLog(); + await this.updateConnections(); + this.initBandwidthGraph(); + } catch (err) { + console.error('Failed to load dashboard:', err); + this.showToast('Error', 'Failed to load system information', 'error'); + } + } + + async updateCpuUsage() { + try { + const [status, result] = await this.ubusCall('file', 'read', { + path: '/proc/stat' + }); + + if (result && result.data) { + const content = result.data; + 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); + + if (this.lastCpuStats) { + const idleDelta = idle - this.lastCpuStats.idle; + const totalDelta = total - this.lastCpuStats.total; + const usage = ((1 - idleDelta / totalDelta) * 100).toFixed(1); + document.getElementById('cpu').textContent = usage + '%'; + document.getElementById('cpu-bar').style.width = usage + '%'; + } + + this.lastCpuStats = { idle, total }; + } + } catch (err) { + document.getElementById('cpu').textContent = 'N/A'; + } + } + + async updateNetworkStats() { + try { + const [status, result] = await this.ubusCall('file', 'read', { + path: '/proc/net/dev' + }); + + if (result && result.data) { + const content = result.data; + 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; + }); + + if (this.lastNetStats) { + const rxRate = (totalRx - this.lastNetStats.rx) / 1024 / 5; + const txRate = (totalTx - this.lastNetStats.tx) / 1024 / 5; + + const downEl = document.getElementById('bandwidth-down'); + const upEl = document.getElementById('bandwidth-up'); + + if (downEl) downEl.textContent = this.formatRate(rxRate); + if (upEl) upEl.textContent = this.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(); + } + + this.updateBandwidthGraph(); + } + + this.lastNetStats = { rx: totalRx, tx: totalTx }; + } + } catch (err) { + console.error('updateNetworkStats error:', err); + document.getElementById('net-rx').textContent = 'N/A'; + document.getElementById('net-tx').textContent = 'N/A'; + } + } + + async updateSystemLog() { + try { + const [status, result] = await this.ubusCall('file', 'exec', { + command: '/usr/libexec/syslog-wrapper' + }); + + if (status === 0 && result && result.stdout) { + const lines = result.stdout + .split('\n') + .filter(l => l.trim()) + .slice(-20); + const logHtml = lines + .map(line => { + let className = 'log-line'; + if (line.toLowerCase().includes('error') || line.toLowerCase().includes('fail')) { + className += ' error'; + } else if (line.toLowerCase().includes('warn')) { + className += ' warn'; + } + return `
${this.escapeHtml(line)}
`; + }) + .join(''); + document.getElementById('system-log').innerHTML = + logHtml || '
No logs available
'; + } else { + document.getElementById('system-log').innerHTML = + '
System log not available
'; + } + } catch (err) { + console.error('Failed to load system log:', err); + document.getElementById('system-log').innerHTML = + '
System log not available
'; + } + } + + async updateConnections() { + try { + const [arpStatus, arpResult] = await this.ubusCall('file', 'read', { + path: '/proc/net/arp' + }).catch(() => [1, null]); + + let deviceCount = 0; + if (arpResult && arpResult.data) { + const lines = arpResult.data.split('\n').slice(1); + deviceCount = lines.filter(line => { + if (!line.trim()) return false; + const parts = line.trim().split(/\s+/); + return parts.length >= 4 && parts[2] !== '0x0'; + }).length; + } + + document.getElementById('clients').textContent = deviceCount; + + const [status, leases] = await this.ubusCall('luci-rpc', 'getDHCPLeases', {}).catch(() => [1, null]); + const tbody = document.querySelector('#connections-table tbody'); + + if (!leases || !leases.dhcp_leases || leases.dhcp_leases.length === 0) { + tbody.innerHTML = + 'No active connections'; + return; + } + + const rows = leases.dhcp_leases + .map( + lease => ` + + ${this.escapeHtml(lease.ipaddr || 'Unknown')} + ${this.escapeHtml(lease.macaddr || 'Unknown')} + ${this.escapeHtml(lease.hostname || 'Unknown')} + Active + + ` + ) + .join(''); + + tbody.innerHTML = rows; + } catch (err) { + console.error('Failed to load connections:', err); + document.getElementById('clients').textContent = 'N/A'; + } + } + + updateLoadGraph() { + const svg = document.getElementById('load-graph'); + const width = 300; + const height = 80; + const data = this.loadHistory; + + if (data.length < 2) return; + + const max = Math.max(...data, 1); + const points = data + .map((val, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - (val / max) * height; + return `${x},${y}`; + }) + .join(' '); + + const line = ``; + const fill = ``; + + svg.innerHTML = svg.innerHTML.split('')[0] + '' + fill + line; + } + + async updateWANStatus() { + try { + const heroCard = document.getElementById('wan-status-hero'); + const wanStatusEl = document.getElementById('wan-status'); + const wanIpEl = document.getElementById('wan-ip'); + const lanIpEl = document.getElementById('lan-ip'); + + if (!heroCard || !wanStatusEl || !wanIpEl || !lanIpEl) return; + + const [status, result] = await this.ubusCall('network.interface', 'dump', {}); + + if (status !== 0 || !result || !result.interface) { + heroCard.classList.add('offline'); + heroCard.classList.remove('online'); + wanStatusEl.textContent = 'UNKNOWN'; + return; + } + + const interfaces = result.interface; + + 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; + } + } + } + + 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'] && internetIface['ipv4-address'][0]) { + wanIpEl.textContent = internetIface['ipv4-address'][0].address; + } else if (gateway) { + wanIpEl.textContent = `Gateway: ${gateway}`; + } else { + wanIpEl.textContent = 'Connected'; + } + } else { + heroCard.classList.add('offline'); + heroCard.classList.remove('online'); + wanStatusEl.textContent = 'OFFLINE'; + wanIpEl.textContent = 'No internet route'; + } + } catch (err) { + console.error('Failed to load WAN status:', err); + } + } + + initBandwidthGraph() { + if (this.bandwidthCanvas && this.bandwidthCtx) return; + + const canvas = document.getElementById('bandwidth-graph'); + if (!canvas) return; + + this.bandwidthCanvas = canvas; + this.bandwidthCtx = canvas.getContext('2d'); + + canvas.width = canvas.offsetWidth; + canvas.height = 200; + } + + updateBandwidthGraph() { + if (!this.bandwidthCtx || !this.bandwidthCanvas) return; + + const ctx = this.bandwidthCtx; + const canvas = this.bandwidthCanvas; + const width = canvas.width; + const height = canvas.height; + const padding = 20; + + ctx.clearRect(0, 0, width, height); + + const downData = this.bandwidthHistory.down; + const upData = this.bandwidthHistory.up; + + if (downData.length < 2) return; + + const max = Math.max(...downData, ...upData, 100); + const stepX = (width - padding * 2) / (downData.length - 1); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineWidth = 1; + for (let i = 0; i <= 4; i++) { + const y = padding + (i * (height - padding * 2)) / 4; + ctx.beginPath(); + ctx.moveTo(padding, y); + ctx.lineTo(width - padding, y); + ctx.stroke(); + } + + ctx.fillStyle = 'rgba(226, 226, 229, 0.15)'; + ctx.beginPath(); + ctx.moveTo(padding, height - padding); + downData.forEach((val, i) => { + const x = padding + i * stepX; + const y = height - padding - (val / max) * (height - padding * 2); + ctx.lineTo(x, y); + }); + ctx.lineTo(width - padding, height - padding); + ctx.closePath(); + ctx.fill(); + + ctx.strokeStyle = 'rgba(226, 226, 229, 0.9)'; + ctx.lineWidth = 2; + ctx.beginPath(); + downData.forEach((val, i) => { + const x = padding + i * stepX; + const y = height - padding - (val / max) * (height - padding * 2); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + + ctx.fillStyle = 'rgba(226, 226, 229, 0.08)'; + ctx.beginPath(); + ctx.moveTo(padding, height - padding); + upData.forEach((val, i) => { + const x = padding + i * stepX; + const y = height - padding - (val / max) * (height - padding * 2); + ctx.lineTo(x, y); + }); + ctx.lineTo(width - padding, height - padding); + ctx.closePath(); + ctx.fill(); + + ctx.strokeStyle = 'rgba(226, 226, 229, 0.5)'; + ctx.lineWidth = 2; + ctx.beginPath(); + upData.forEach((val, i) => { + const x = padding + i * stepX; + const y = height - padding - (val / max) * (height - padding * 2); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + } + + startPolling() { + this.stopPolling(); + this.pollInterval = setInterval(() => { + const currentPage = document.querySelector('.page:not(.hidden)'); + if (currentPage && currentPage.id === 'dashboard-page') { + this.loadDashboard(); + } + }, 5000); + } + + stopPolling() { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + } + + async rebootSystem() { + if (!confirm('Are you sure you want to reboot the system?')) return; + try { + await this.ubusCall('system', 'reboot', {}); + this.showToast('Success', 'System is rebooting...', 'success'); + setTimeout(() => this.logout(), 2000); + } catch (err) { + this.showToast('Error', 'Failed to reboot system', 'error'); + } + } + + async restartNetwork() { + if (!confirm('Restart network services? This may interrupt connectivity.')) return; + try { + await this.ubusCall('file', 'exec', { command: '/etc/init.d/network', params: ['restart'] }); + this.showToast('Success', 'Network services restarting...', 'success'); + } catch (err) { + this.showToast('Error', 'Failed to restart network', 'error'); + } + } + + async restartFirewall() { + try { + await this.ubusCall('file', 'exec', { command: '/etc/init.d/firewall', params: ['restart'] }); + this.showToast('Success', 'Firewall restarted successfully', 'success'); + } catch (err) { + this.showToast('Error', 'Failed to restart firewall', 'error'); + } + } + + formatUptime(seconds) { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${days}d ${hours}h ${minutes}m`; + } + + formatMemory(mem) { + const total = (mem.total / 1024 / 1024).toFixed(0); + const free = (mem.free / 1024 / 1024).toFixed(0); + const used = total - free; + const percent = ((used / total) * 100).toFixed(0); + return `${used}MB / ${total}MB (${percent}%)`; + } + + formatRate(kbps) { + const mbps = (kbps * 8) / 1024; + if (mbps < 0.01) return '0 Mbps'; + if (mbps < 1) return `${mbps.toFixed(2)} Mbps`; + return `${mbps.toFixed(1)} Mbps`; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + showLoginView() { + document.getElementById('login-view').classList.remove('hidden'); + document.getElementById('main-view').classList.add('hidden'); + + const savedCreds = this.getSavedCredentials(); + if (savedCreds) { + document.getElementById('username').value = savedCreds.username; + document.getElementById('remember-me').checked = true; + } + } + + showMainView() { + document.getElementById('login-view').classList.add('hidden'); + document.getElementById('main-view').classList.remove('hidden'); + } + + showError(message) { + const errorEl = document.getElementById('login-error'); + if (errorEl) { + errorEl.textContent = message; + setTimeout(() => (errorEl.textContent = ''), 3000); + } + } + + showToast(title, message, type = 'info') { + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = ` +
${this.escapeHtml(title)}
+
${this.escapeHtml(message)}
+ `; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 4000); + } + + attachEventListeners() { + document.getElementById('login-form').addEventListener('submit', async e => { + e.preventDefault(); + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + const success = await this.login(username, password); + if (success) { + this.showMainView(); + this.loadDashboard(); + this.startPolling(); + } else { + this.showError('Invalid credentials'); + } + }); + + document.getElementById('logout-btn').addEventListener('click', () => { + this.logout(); + }); + + document.getElementById('reboot-btn').addEventListener('click', () => { + this.rebootSystem(); + }); + + document.getElementById('restart-network-btn').addEventListener('click', () => { + this.restartNetwork(); + }); + + document.getElementById('restart-firewall-btn').addEventListener('click', () => { + this.restartFirewall(); + }); + + document.querySelectorAll('.nav a').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const page = e.target.dataset.page; + this.navigateTo(page); + }); + }); + + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', e => { + const tabName = e.target.dataset.tab; + const page = e.target.closest('.page'); + this.switchTab(page, tabName); + }); + }); + + document.getElementById('ping-btn').addEventListener('click', () => { + this.runPing(); + }); + + document.getElementById('traceroute-btn').addEventListener('click', () => { + this.runTraceroute(); + }); + + document.getElementById('wol-btn').addEventListener('click', () => { + this.sendWakeOnLan(); + }); + + document.getElementById('close-interface-modal').addEventListener('click', () => { + this.closeInterfaceConfig(); + }); + + document.getElementById('cancel-interface-btn').addEventListener('click', () => { + this.closeInterfaceConfig(); + }); + + document.getElementById('save-interface-btn').addEventListener('click', () => { + this.saveInterfaceConfig(); + }); + + document.getElementById('edit-iface-proto').addEventListener('change', () => { + this.updateStaticConfigVisibility(); + }); + + document.querySelector('.modal-backdrop')?.addEventListener('click', () => { + this.closeInterfaceConfig(); + }); + + document.getElementById('close-wireless-modal').addEventListener('click', () => { + this.closeWirelessConfig(); + }); + + document.getElementById('cancel-wireless-btn').addEventListener('click', () => { + this.closeWirelessConfig(); + }); + + document.getElementById('save-wireless-btn').addEventListener('click', () => { + this.saveWirelessConfig(); + }); + + document.getElementById('edit-wifi-encryption').addEventListener('change', () => { + this.updateWirelessKeyVisibility(); + }); + + document.getElementById('add-forward-btn').addEventListener('click', () => { + this.openForwardRule(); + }); + + document.getElementById('close-forward-modal').addEventListener('click', () => { + this.closeForwardRule(); + }); + + document.getElementById('cancel-forward-btn').addEventListener('click', () => { + this.closeForwardRule(); + }); + + document.getElementById('save-forward-btn').addEventListener('click', () => { + this.saveForwardRule(); + }); + + document.getElementById('add-fw-rule-btn').addEventListener('click', () => { + document.getElementById('edit-fw-rule-section').value = ''; + document.getElementById('edit-fw-rule-name').value = ''; + document.getElementById('edit-fw-rule-target').value = 'ACCEPT'; + document.getElementById('edit-fw-rule-src').value = ''; + document.getElementById('edit-fw-rule-dest').value = ''; + document.getElementById('edit-fw-rule-proto').value = ''; + document.getElementById('edit-fw-rule-dest-port').value = ''; + document.getElementById('edit-fw-rule-src-ip').value = ''; + this.openModal('fw-rule-modal'); + }); + + this.setupModal('fw-rule-modal', null, 'close-fw-rule-modal', 'cancel-fw-rule-btn', 'save-fw-rule-btn', () => + this.saveFirewallRule() + ); + + document.getElementById('add-static-lease-btn').addEventListener('click', () => { + this.openStaticLease(); + }); + + document.getElementById('close-static-lease-modal').addEventListener('click', () => { + this.closeStaticLease(); + }); + + document.getElementById('cancel-static-lease-btn').addEventListener('click', () => { + this.closeStaticLease(); + }); + + document.getElementById('save-static-lease-btn').addEventListener('click', () => { + this.saveStaticLease(); + }); + + document.getElementById('add-dns-entry-btn').addEventListener('click', () => { + document.getElementById('edit-dns-entry-section').value = ''; + document.getElementById('edit-dns-hostname').value = ''; + document.getElementById('edit-dns-ip').value = ''; + this.openModal('dns-entry-modal'); + }); + + this.setupModal( + 'dns-entry-modal', + null, + 'close-dns-entry-modal', + 'cancel-dns-entry-btn', + 'save-dns-entry-btn', + () => this.saveDNSEntry() + ); + + document.getElementById('add-host-entry-btn').addEventListener('click', () => { + document.getElementById('edit-host-entry-index').value = ''; + document.getElementById('edit-host-ip').value = ''; + document.getElementById('edit-host-names').value = ''; + this.openModal('host-entry-modal'); + }); + + this.setupModal( + 'host-entry-modal', + null, + 'close-host-entry-modal', + 'cancel-host-entry-btn', + 'save-host-entry-btn', + () => this.saveHostEntry() + ); + + document.getElementById('add-ddns-btn').addEventListener('click', () => { + document.getElementById('edit-ddns-section').value = ''; + document.getElementById('edit-ddns-name').value = ''; + document.getElementById('edit-ddns-service').value = 'dyndns.org'; + document.getElementById('edit-ddns-hostname').value = ''; + document.getElementById('edit-ddns-username').value = ''; + document.getElementById('edit-ddns-password').value = ''; + document.getElementById('edit-ddns-check-interval').value = '10'; + document.getElementById('edit-ddns-enabled').value = '1'; + this.openModal('ddns-modal'); + }); + + this.setupModal('ddns-modal', null, 'close-ddns-modal', 'cancel-ddns-btn', 'save-ddns-btn', () => + this.saveDDNS() + ); + + document.getElementById('save-qos-config-btn').addEventListener('click', () => { + this.saveQoSConfig(); + }); + + document.getElementById('add-qos-rule-btn').addEventListener('click', () => { + document.getElementById('edit-qos-rule-section').value = ''; + document.getElementById('edit-qos-rule-name').value = ''; + document.getElementById('edit-qos-rule-priority').value = 'Normal'; + document.getElementById('edit-qos-rule-proto').value = ''; + document.getElementById('edit-qos-rule-ports').value = ''; + document.getElementById('edit-qos-rule-srchost').value = ''; + this.openModal('qos-rule-modal'); + }); + + this.setupModal( + 'qos-rule-modal', + null, + 'close-qos-rule-modal', + 'cancel-qos-rule-btn', + 'save-qos-rule-btn', + () => this.saveQoSRule() + ); + + document.getElementById('generate-wg-keys-btn').addEventListener('click', () => { + this.generateWireGuardKeys(); + }); + + document.getElementById('save-wg-config-btn').addEventListener('click', () => { + this.saveWireGuardConfig(); + }); + + document.getElementById('add-wg-peer-btn').addEventListener('click', () => { + document.getElementById('edit-wg-peer-section').value = ''; + document.getElementById('edit-wg-peer-name').value = ''; + document.getElementById('edit-wg-peer-public-key').value = ''; + document.getElementById('edit-wg-peer-allowed-ips').value = ''; + document.getElementById('edit-wg-peer-keepalive').value = '25'; + document.getElementById('edit-wg-peer-preshared-key').value = ''; + this.openModal('wg-peer-modal'); + }); + + this.setupModal('wg-peer-modal', null, 'close-wg-peer-modal', 'cancel-wg-peer-btn', 'save-wg-peer-btn', () => + this.saveWireGuardPeer() + ); + + document.getElementById('add-cron-btn').addEventListener('click', () => { + this.openCronJob(); + }); + + document.getElementById('close-cron-modal').addEventListener('click', () => { + this.closeCronJob(); + }); + + document.getElementById('cancel-cron-btn').addEventListener('click', () => { + this.closeCronJob(); + }); + + document.getElementById('save-cron-btn').addEventListener('click', () => { + this.saveCronJob(); + }); + + document.getElementById('add-ssh-key-btn').addEventListener('click', () => { + this.openSSHKey(); + }); + + document.getElementById('close-ssh-key-modal').addEventListener('click', () => { + this.closeSSHKey(); + }); + + document.getElementById('cancel-ssh-key-btn').addEventListener('click', () => { + this.closeSSHKey(); + }); + + document.getElementById('parse-keys-btn').addEventListener('click', () => { + this.parseSSHKeys(); + }); + + document.getElementById('save-ssh-keys-btn').addEventListener('click', () => { + this.saveSSHKeys(); + }); + + document.getElementById('backup-btn').addEventListener('click', () => { + this.generateBackup(); + }); + + document.getElementById('reset-btn').addEventListener('click', () => { + this.resetToDefaults(); + }); + + document.getElementById('change-password-btn')?.addEventListener('click', () => { + this.changePassword(); + }); + + document.getElementById('save-general-btn')?.addEventListener('click', () => { + this.saveGeneralSettings(); + }); + + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + this.stopPolling(); + } else { + const currentPage = document.querySelector('.page:not(.hidden)'); + if (currentPage && currentPage.id === 'dashboard-page') { + this.startPolling(); + } + } + }); + } + + navigateTo(page) { + document.querySelectorAll('.page').forEach(p => p.classList.add('hidden')); + document.querySelectorAll('.nav a').forEach(a => a.classList.remove('active')); + + document.getElementById(`${page}-page`).classList.remove('hidden'); + document.querySelector(`[data-page="${page}"]`).classList.add('active'); + + if (page === 'dashboard') { + this.loadDashboard(); + this.startPolling(); + } else { + this.stopPolling(); + if (page === 'network') { + this.loadNetworkData(); + } else if (page === 'system') { + this.loadSystemData(); + } + } + } + + switchTab(page, tabName) { + page.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + page.querySelectorAll('.tab-content').forEach(content => content.classList.add('hidden')); + + page.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); + page.querySelector(`#tab-${tabName}`).classList.remove('hidden'); + + const tabLoaders = { + interfaces: () => this.loadNetworkInterfaces(), + wireless: () => this.loadWireless(), + firewall: () => this.loadFirewallRules(), + dhcp: () => this.loadDHCPLeases(), + dns: () => this.loadDNS(), + ddns: () => this.loadDDNS(), + qos: () => this.loadQoS(), + vpn: () => this.loadWireGuard(), + startup: () => this.loadServices(), + software: () => this.loadPackages(), + cron: () => this.loadCronJobs(), + 'ssh-keys': () => this.loadSSHKeys(), + mounts: () => this.loadMountPoints(), + led: () => this.loadLEDs(), + upgrade: () => this.initFirmwareUpgrade() + }; + + tabLoaders[tabName]?.(); + } + + async loadNetworkData() { + this.loadNetworkInterfaces(); + } + + async loadSystemData() { + const [status, boardInfo] = await this.ubusCall('system', 'board', {}); + if (boardInfo) { + const hostnameInput = document.getElementById('system-hostname'); + if (hostnameInput) { + hostnameInput.value = boardInfo.hostname || 'OpenWrt'; + } + } + } + + async loadNetworkInterfaces() { + try { + const [status, result] = await this.ubusCall('network.interface', 'dump', {}); + const tbody = document.querySelector('#interfaces-table tbody'); + + if (!result || !result.interface || result.interface.length === 0) { + tbody.innerHTML = + 'No interfaces found'; + return; + } + + const rows = result.interface + .map(iface => { + const statusBadge = iface.up + ? 'UP' + : 'DOWN'; + const ipaddr = + iface['ipv4-address'] && iface['ipv4-address'][0] ? iface['ipv4-address'][0].address : 'N/A'; + const rxBytes = ((iface.statistics?.rx_bytes || 0) / 1024 / 1024).toFixed(2); + const txBytes = ((iface.statistics?.tx_bytes || 0) / 1024 / 1024).toFixed(2); + const proto = iface.proto || 'unknown'; + + return ` + + ${this.escapeHtml(iface.interface || 'Unknown')} + ${this.escapeHtml(proto).toUpperCase()} + ${statusBadge} + ${this.escapeHtml(ipaddr)} + ${rxBytes} / ${txBytes} MB + + Configure + + + `; + }) + .join(''); + + tbody.innerHTML = rows; + + document.querySelectorAll('#interfaces-table .action-link').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const ifaceName = e.target.dataset.iface; + this.openInterfaceConfig(ifaceName); + }); + }); + } catch (err) { + console.error('Failed to load network interfaces:', err); + const tbody = document.querySelector('#interfaces-table tbody'); + tbody.innerHTML = + 'Failed to load interfaces'; + } + } + + async openInterfaceConfig(ifaceName) { + try { + const [status, config] = await this.ubusCall('uci', 'get', { + config: 'network', + section: ifaceName + }); + + document.getElementById('edit-iface-name').value = ifaceName; + document.getElementById('edit-iface-proto').value = config.values.proto || 'static'; + document.getElementById('edit-iface-ipaddr').value = config.values.ipaddr || ''; + document.getElementById('edit-iface-netmask').value = config.values.netmask || ''; + document.getElementById('edit-iface-gateway').value = config.values.gateway || ''; + + const dns = config.values.dns || []; + const dnsStr = Array.isArray(dns) ? dns.join(' ') : dns || ''; + document.getElementById('edit-iface-dns').value = dnsStr; + + this.updateStaticConfigVisibility(); + document.getElementById('interface-modal').classList.remove('hidden'); + } catch (err) { + console.error('Failed to load interface config:', err); + this.showToast('Error', 'Failed to load interface configuration', 'error'); + } + } + + closeInterfaceConfig() { + document.getElementById('interface-modal').classList.add('hidden'); + } + + updateStaticConfigVisibility() { + const proto = document.getElementById('edit-iface-proto').value; + const staticConfig = document.getElementById('static-config'); + if (proto === 'static') { + staticConfig.style.display = 'block'; + } else { + staticConfig.style.display = 'none'; + } + } + + async saveInterfaceConfig() { + try { + const ifaceName = document.getElementById('edit-iface-name').value; + const proto = document.getElementById('edit-iface-proto').value; + + await this.ubusCall('uci', 'set', { + config: 'network', + section: ifaceName, + values: { + proto: proto + } + }); + + if (proto === 'static') { + const ipaddr = document.getElementById('edit-iface-ipaddr').value; + const netmask = document.getElementById('edit-iface-netmask').value; + const gateway = document.getElementById('edit-iface-gateway').value; + const dns = document + .getElementById('edit-iface-dns') + .value.split(/\s+/) + .filter(d => d); + + const staticValues = { proto }; + if (ipaddr) staticValues.ipaddr = ipaddr; + if (netmask) staticValues.netmask = netmask; + if (gateway) staticValues.gateway = gateway; + if (dns.length > 0) staticValues.dns = dns; + + await this.ubusCall('uci', 'set', { + config: 'network', + section: ifaceName, + values: staticValues + }); + } + + await this.ubusCall('uci', 'commit', { + config: 'network' + }); + + await this.ubusCall('file', 'exec', { + command: '/etc/init.d/network', + params: ['reload'] + }); + + this.showToast('Success', 'Interface configuration saved', 'success'); + this.closeInterfaceConfig(); + setTimeout(() => this.loadNetworkInterfaces(), 2000); + } catch (err) { + console.error('Failed to save interface config:', err); + this.showToast('Error', 'Failed to save configuration', 'error'); + } + } + + async loadWireless() { + try { + const [status, config] = await this.ubusCall('uci', 'get', { + config: 'wireless' + }); + + const tbody = document.querySelector('#wireless-table tbody'); + const rows = []; + + if (!config || !config.values) { + tbody.innerHTML = + 'No wireless devices found'; + return; + } + + for (const [section, sectionData] of Object.entries(config.values)) { + if (sectionData['.type'] === 'wifi-iface') { + const radio = sectionData.device || 'unknown'; + const ssid = sectionData.ssid || 'N/A'; + const disabled = sectionData.disabled === '1'; + const encryption = sectionData.encryption || 'none'; + + const statusBadge = disabled + ? 'DISABLED' + : 'ENABLED'; + + let radioInfo = await this.getRadioInfo(radio); + const channel = radioInfo.channel || 'Auto'; + const signal = radioInfo.signal || 'N/A'; + + rows.push(` + + ${this.escapeHtml(radio)} + ${this.escapeHtml(ssid)} + ${this.escapeHtml(String(channel))} + ${statusBadge} + ${this.escapeHtml(encryption)} + + Configure + + + `); + } + } + + if (rows.length === 0) { + tbody.innerHTML = + 'No wireless interfaces found'; + } else { + tbody.innerHTML = rows.join(''); + + document.querySelectorAll('#wireless-table .action-link').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const section = e.target.dataset.wifiSection; + const radio = e.target.dataset.wifiRadio; + this.openWirelessConfig(section, radio); + }); + }); + } + } catch (err) { + console.error('Failed to load wireless:', err); + const tbody = document.querySelector('#wireless-table tbody'); + tbody.innerHTML = + 'Failed to load wireless'; + } + } + + async getRadioInfo(radio) { + try { + const [status, config] = await this.ubusCall('uci', 'get', { + config: 'wireless', + section: radio + }); + return config?.values || {}; + } catch { + return {}; + } + } + + async openWirelessConfig(section, radio) { + try { + const [status, config] = await this.ubusCall('uci', 'get', { + config: 'wireless', + section: section + }); + + const values = config.values; + document.getElementById('edit-wifi-section').value = section; + document.getElementById('edit-wifi-radio').value = radio; + document.getElementById('edit-wifi-ssid').value = values.ssid || ''; + document.getElementById('edit-wifi-encryption').value = values.encryption || 'none'; + document.getElementById('edit-wifi-key').value = values.key || ''; + document.getElementById('edit-wifi-disabled').value = values.disabled || '0'; + document.getElementById('edit-wifi-hidden').value = values.hidden || '0'; + + const [radioStatus, radioConfig] = await this.ubusCall('uci', 'get', { + config: 'wireless', + section: radio + }); + + const radioValues = radioConfig.values; + const channelSelect = document.getElementById('edit-wifi-channel'); + const currentChannel = radioValues.channel || 'auto'; + const band = radioValues.band || radioValues.hwmode || '2g'; + + channelSelect.innerHTML = ''; + if (band.includes('5') || band.includes('a')) { + for (let ch of [36, 40, 44, 48, 149, 153, 157, 161, 165]) { + channelSelect.innerHTML += ``; + } + } else { + for (let ch = 1; ch <= 13; ch++) { + channelSelect.innerHTML += ``; + } + } + channelSelect.value = currentChannel; + + document.getElementById('edit-wifi-txpower').value = radioValues.txpower || ''; + + this.updateWirelessKeyVisibility(); + document.getElementById('wireless-modal').classList.remove('hidden'); + } catch (err) { + console.error('Failed to load wireless config:', err); + this.showToast('Error', 'Failed to load wireless configuration', 'error'); + } + } + + closeWirelessConfig() { + document.getElementById('wireless-modal').classList.add('hidden'); + } + + updateWirelessKeyVisibility() { + const encryption = document.getElementById('edit-wifi-encryption').value; + const keyGroup = document.getElementById('wifi-key-group'); + if (encryption === 'none') { + keyGroup.style.display = 'none'; + } else { + keyGroup.style.display = 'block'; + } + } + + async saveWirelessConfig() { + try { + const section = document.getElementById('edit-wifi-section').value; + const radio = document.getElementById('edit-wifi-radio').value; + const ssid = document.getElementById('edit-wifi-ssid').value; + const encryption = document.getElementById('edit-wifi-encryption').value; + const key = document.getElementById('edit-wifi-key').value; + const disabled = document.getElementById('edit-wifi-disabled').value; + const hidden = document.getElementById('edit-wifi-hidden').value; + const channel = document.getElementById('edit-wifi-channel').value; + const txpower = document.getElementById('edit-wifi-txpower').value; + + if (!ssid) { + this.showToast('Error', 'SSID is required', 'error'); + return; + } + + if (encryption !== 'none' && (!key || key.length < 8)) { + this.showToast('Error', 'Password must be at least 8 characters', 'error'); + return; + } + + const ifaceValues = { ssid, encryption, disabled, hidden }; + if (encryption !== 'none') { + ifaceValues.key = key; + } + + await this.ubusCall('uci', 'set', { + config: 'wireless', + section: section, + values: ifaceValues + }); + + const radioValues = {}; + if (channel) radioValues.channel = channel; + if (txpower) radioValues.txpower = txpower; + + if (Object.keys(radioValues).length > 0) { + await this.ubusCall('uci', 'set', { + config: 'wireless', + section: radio, + values: radioValues + }); + } + + await this.ubusCall('uci', 'commit', { + config: 'wireless' + }); + + await this.ubusCall('file', 'exec', { + command: '/sbin/wifi', + params: ['reload'] + }); + + this.showToast('Success', 'Wireless configuration saved. WiFi reloading...', 'success'); + this.closeWirelessConfig(); + setTimeout(() => this.loadWireless(), 3000); + } catch (err) { + console.error('Failed to save wireless config:', err); + this.showToast('Error', 'Failed to save configuration', 'error'); + } + } + + async loadFirewallRules() { + await this.loadPortForwarding(); + await this.loadFirewallGeneralRules(); + } + + async loadPortForwarding() { + try { + const [status, config] = await this.uciGet('firewall'); + const tbody = document.querySelector('#firewall-table tbody'); + const rows = []; + + if (!config || !config.values) { + tbody.innerHTML = + 'No rules configured'; + return; + } + + for (const [section, sectionData] of Object.entries(config.values)) { + if (sectionData['.type'] === 'redirect') { + const name = sectionData.name || section; + const proto = sectionData.proto || 'tcp'; + const srcDport = sectionData.src_dport || 'N/A'; + const destIp = sectionData.dest_ip || 'N/A'; + const destPort = sectionData.dest_port || srcDport; + const enabled = sectionData.enabled !== '0'; + + const statusBadge = enabled + ? 'YES' + : 'NO'; + + rows.push(` + + ${this.escapeHtml(name)} + ${this.escapeHtml(proto).toUpperCase()} + ${this.escapeHtml(srcDport)} + ${this.escapeHtml(destIp)} + ${this.escapeHtml(destPort)} + ${statusBadge} + + Edit | + Delete + + + `); + } + } + + if (rows.length === 0) { + tbody.innerHTML = + 'No rules configured'; + } else { + tbody.innerHTML = rows.join(''); + + document.querySelectorAll('#firewall-table .action-link').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const section = e.target.dataset.forwardSection; + this.openForwardRule(section); + }); + }); + + document.querySelectorAll('#firewall-table .action-link-danger').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const section = e.target.dataset.forwardDelete; + this.deleteForwardRule(section); + }); + }); + } + } catch (err) { + console.error('Failed to load firewall rules:', err); + const tbody = document.querySelector('#firewall-table tbody'); + tbody.innerHTML = + 'Failed to load rules'; + } + } + + async openForwardRule(section = null) { + try { + if (section) { + const [status, config] = await this.ubusCall('uci', 'get', { + config: 'firewall', + section: section + }); + + const values = config.values; + document.getElementById('edit-forward-section').value = section; + document.getElementById('edit-forward-name').value = values.name || ''; + document.getElementById('edit-forward-proto').value = values.proto || 'tcp'; + document.getElementById('edit-forward-src-dport').value = values.src_dport || ''; + document.getElementById('edit-forward-dest-ip').value = values.dest_ip || ''; + document.getElementById('edit-forward-dest-port').value = values.dest_port || ''; + document.getElementById('edit-forward-enabled').value = values.enabled === '0' ? '0' : '1'; + } else { + document.getElementById('edit-forward-section').value = ''; + document.getElementById('edit-forward-name').value = ''; + document.getElementById('edit-forward-proto').value = 'tcp'; + document.getElementById('edit-forward-src-dport').value = ''; + document.getElementById('edit-forward-dest-ip').value = ''; + document.getElementById('edit-forward-dest-port').value = ''; + document.getElementById('edit-forward-enabled').value = '1'; + } + + document.getElementById('forward-modal').classList.remove('hidden'); + } catch (err) { + console.error('Failed to load forward rule:', err); + this.showToast('Error', 'Failed to load rule configuration', 'error'); + } + } + + closeForwardRule() { + document.getElementById('forward-modal').classList.add('hidden'); + } + + async saveForwardRule() { + try { + const section = document.getElementById('edit-forward-section').value; + const name = document.getElementById('edit-forward-name').value; + const proto = document.getElementById('edit-forward-proto').value; + const srcDport = document.getElementById('edit-forward-src-dport').value; + const destIp = document.getElementById('edit-forward-dest-ip').value; + const destPort = document.getElementById('edit-forward-dest-port').value; + const enabled = document.getElementById('edit-forward-enabled').value; + + if (!name || !srcDport || !destIp) { + this.showToast('Error', 'Name, external port, and internal IP are required', 'error'); + return; + } + + const values = { + name, + src: 'wan', + proto, + src_dport: srcDport, + dest: 'lan', + dest_ip: destIp, + target: 'DNAT', + enabled + }; + + if (destPort) { + values.dest_port = destPort; + } + + if (section) { + await this.ubusCall('uci', 'set', { + config: 'firewall', + section: section, + values: values + }); + } else { + await this.ubusCall('uci', 'add', { + config: 'firewall', + type: 'redirect', + name: name, + values: values + }); + } + + await this.ubusCall('uci', 'commit', { + config: 'firewall' + }); + + await this.ubusCall('file', 'exec', { + command: '/etc/init.d/firewall', + params: ['reload'] + }); + + this.showToast('Success', 'Port forwarding rule saved', 'success'); + this.closeForwardRule(); + setTimeout(() => this.loadFirewallRules(), 2000); + } catch (err) { + console.error('Failed to save forward rule:', err); + this.showToast('Error', 'Failed to save rule', 'error'); + } + } + + async deleteForwardRule(section) { + if (!confirm('Delete this port forwarding rule?')) return; + + try { + await this.ubusCall('uci', 'delete', { + config: 'firewall', + section: section + }); + + await this.ubusCall('uci', 'commit', { + config: 'firewall' + }); + + await this.ubusCall('file', 'exec', { + command: '/etc/init.d/firewall', + params: ['reload'] + }); + + this.showToast('Success', 'Rule deleted', 'success'); + setTimeout(() => this.loadFirewallRules(), 2000); + } catch (err) { + console.error('Failed to delete rule:', err); + this.showToast('Error', 'Failed to delete rule', 'error'); + } + } + + async loadFirewallGeneralRules() { + try { + const [status, config] = await this.uciGet('firewall'); + const tbody = document.querySelector('#fw-rules-table tbody'); + + if (!config || !config.values) { + this.renderEmptyTable(tbody, 7, 'No firewall rules'); + return; + } + + const rows = []; + for (const [section, data] of Object.entries(config.values)) { + if (data['.type'] === 'rule' && data.name) { + const name = data.name || section; + const src = data.src || 'any'; + const dest = data.dest || 'any'; + const proto = data.proto || 'any'; + const destPort = data.dest_port || 'any'; + const target = data.target || 'ACCEPT'; + + rows.push(` + + ${this.escapeHtml(name)} + ${this.escapeHtml(src)} + ${this.escapeHtml(dest)} + ${this.escapeHtml(proto).toUpperCase()} + ${this.escapeHtml(destPort)} + ${this.renderBadge(target === 'ACCEPT' ? 'success' : 'error', target)} + ${this.renderActionButtons('editFirewallRule', 'deleteFirewallRule', section)} + + `); + } + } + + if (rows.length === 0) { + this.renderEmptyTable(tbody, 7, 'No firewall rules'); + } else { + tbody.innerHTML = rows.join(''); + } + } catch (err) { + console.error('Failed to load firewall rules:', err); + this.renderEmptyTable(document.querySelector('#fw-rules-table tbody'), 7, 'Failed to load rules'); + } + } + + async editFirewallRule(section) { + try { + const [status, data] = await this.uciGet('firewall', section); + + if (status === 0 && data && data.values) { + const values = data.values; + document.getElementById('edit-fw-rule-section').value = section; + document.getElementById('edit-fw-rule-name').value = values.name || ''; + document.getElementById('edit-fw-rule-target').value = values.target || 'ACCEPT'; + document.getElementById('edit-fw-rule-src').value = values.src || ''; + document.getElementById('edit-fw-rule-dest').value = values.dest || ''; + document.getElementById('edit-fw-rule-proto').value = values.proto || ''; + document.getElementById('edit-fw-rule-dest-port').value = values.dest_port || ''; + document.getElementById('edit-fw-rule-src-ip').value = values.src_ip || ''; + this.openModal('fw-rule-modal'); + } + } catch (err) { + console.error('Failed to load firewall rule:', err); + this.showToast('Error', 'Failed to load rule', 'error'); + } + } + + async saveFirewallRule() { + try { + const section = this.getFormValue('edit-fw-rule-section'); + const name = this.getFormValue('edit-fw-rule-name'); + const target = this.getFormValue('edit-fw-rule-target'); + const src = this.getFormValue('edit-fw-rule-src'); + const dest = this.getFormValue('edit-fw-rule-dest'); + const proto = this.getFormValue('edit-fw-rule-proto'); + const destPort = this.getFormValue('edit-fw-rule-dest-port'); + const srcIp = this.getFormValue('edit-fw-rule-src-ip'); + + if (!name) { + this.showToast('Error', 'Please provide a rule name', 'error'); + return; + } + + const values = { name, target }; + if (src) values.src = src; + if (dest) values.dest = dest; + if (proto) values.proto = proto; + if (destPort) values.dest_port = destPort; + if (srcIp) values.src_ip = srcIp; + + await this.saveUciConfig({ + config: 'firewall', + section: section, + values: values, + service: 'firewall', + modal: 'fw-rule-modal', + successMsg: 'Firewall rule saved', + reload: () => this.loadFirewallGeneralRules(), + isAdd: !section, + addType: 'rule' + }); + } catch (err) { + console.error('Failed to save firewall rule:', err); + this.showToast('Error', 'Failed to save rule', 'error'); + } + } + + async deleteFirewallRule(section) { + if (!confirm('Delete this firewall rule?')) return; + + try { + await this.uciDelete('firewall', section); + await this.uciCommit('firewall'); + await this.serviceReload('firewall'); + + this.showToast('Success', 'Firewall rule deleted', 'success'); + await this.loadFirewallGeneralRules(); + } catch (err) { + console.error('Failed to delete firewall rule:', err); + this.showToast('Error', 'Failed to delete rule', 'error'); + } + } + + async loadDHCPLeases() { + try { + const [status, result] = await this.ubusCall('luci-rpc', 'getDHCPLeases', {}).catch(() => [1, null]); + const tbody = document.querySelector('#dhcp-leases-table tbody'); + + if (!result || !result.dhcp_leases || result.dhcp_leases.length === 0) { + tbody.innerHTML = + 'No active leases'; + } else { + const rows = result.dhcp_leases + .map(lease => { + const expires = lease.expires ? `${Math.floor(lease.expires / 60)}m` : 'Static'; + return ` + + ${this.escapeHtml(lease.hostname || 'Unknown')} + ${this.escapeHtml(lease.ipaddr || 'Unknown')} + ${this.escapeHtml(lease.macaddr || 'Unknown')} + ${expires} + + `; + }) + .join(''); + tbody.innerHTML = rows; + } + + await this.loadStaticLeases(); + } catch (err) { + console.error('Failed to load DHCP leases:', err); + } + } + + async loadStaticLeases() { + try { + const [status, config] = await this.ubusCall('uci', 'get', { + config: 'dhcp' + }); + + const tbody = document.querySelector('#dhcp-static-table tbody'); + const rows = []; + + if (!config || !config.values) { + tbody.innerHTML = + 'No static leases'; + return; + } + + for (const [section, sectionData] of Object.entries(config.values)) { + if (sectionData['.type'] === 'host') { + const name = sectionData.name || section; + const mac = sectionData.mac || 'N/A'; + const ip = sectionData.ip || 'N/A'; + + rows.push(` + + ${this.escapeHtml(name)} + ${this.escapeHtml(mac)} + ${this.escapeHtml(ip)} + + Edit | + Delete + + + `); + } + } + + if (rows.length === 0) { + tbody.innerHTML = + 'No static leases'; + } else { + tbody.innerHTML = rows.join(''); + + document.querySelectorAll('#dhcp-static-table .action-link').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const section = e.target.dataset.staticLeaseSection; + this.openStaticLease(section); + }); + }); + + document.querySelectorAll('#dhcp-static-table .action-link-danger').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const section = e.target.dataset.staticLeaseDelete; + this.deleteStaticLease(section); + }); + }); + } + } catch (err) { + console.error('Failed to load static leases:', err); + const tbody = document.querySelector('#dhcp-static-table tbody'); + tbody.innerHTML = + 'Failed to load static leases'; + } + } + + async openStaticLease(section = null) { + try { + if (section) { + const [status, config] = await this.ubusCall('uci', 'get', { + config: 'dhcp', + section: section + }); + + const values = config.values; + document.getElementById('edit-static-lease-section').value = section; + document.getElementById('edit-static-lease-name').value = values.name || ''; + document.getElementById('edit-static-lease-mac').value = values.mac || ''; + document.getElementById('edit-static-lease-ip').value = values.ip || ''; + } else { + document.getElementById('edit-static-lease-section').value = ''; + document.getElementById('edit-static-lease-name').value = ''; + document.getElementById('edit-static-lease-mac').value = ''; + document.getElementById('edit-static-lease-ip').value = ''; + } + + document.getElementById('static-lease-modal').classList.remove('hidden'); + } catch (err) { + console.error('Failed to load static lease:', err); + this.showToast('Error', 'Failed to load lease configuration', 'error'); + } + } + + closeStaticLease() { + document.getElementById('static-lease-modal').classList.add('hidden'); + } + + async saveStaticLease() { + try { + const section = document.getElementById('edit-static-lease-section').value; + const name = document.getElementById('edit-static-lease-name').value; + const mac = document.getElementById('edit-static-lease-mac').value; + const ip = document.getElementById('edit-static-lease-ip').value; + + if (!mac || !ip) { + this.showToast('Error', 'MAC address and IP address are required', 'error'); + return; + } + + const values = { name: name || mac, mac, ip }; + + if (section) { + await this.ubusCall('uci', 'set', { + config: 'dhcp', + section: section, + values: values + }); + } else { + await this.ubusCall('uci', 'add', { + config: 'dhcp', + type: 'host', + name: name || mac, + values: values + }); + } + + await this.ubusCall('uci', 'commit', { + config: 'dhcp' + }); + + await this.ubusCall('file', 'exec', { + command: '/etc/init.d/dnsmasq', + params: ['reload'] + }); + + this.showToast('Success', 'Static DHCP lease saved', 'success'); + this.closeStaticLease(); + setTimeout(() => this.loadStaticLeases(), 2000); + } catch (err) { + console.error('Failed to save static lease:', err); + this.showToast('Error', 'Failed to save lease', 'error'); + } + } + + async deleteStaticLease(section) { + if (!confirm('Delete this static DHCP lease?')) return; + + try { + await this.ubusCall('uci', 'delete', { + config: 'dhcp', + section: section + }); + + await this.ubusCall('uci', 'commit', { + config: 'dhcp' + }); + + await this.ubusCall('file', 'exec', { + command: '/etc/init.d/dnsmasq', + params: ['reload'] + }); + + this.showToast('Success', 'Static lease deleted', 'success'); + setTimeout(() => this.loadStaticLeases(), 2000); + } catch (err) { + console.error('Failed to delete static lease:', err); + this.showToast('Error', 'Failed to delete lease', 'error'); + } + } + + async loadDNS() { + await this.loadDNSEntries(); + await this.loadHostsEntries(); + } + + async loadDNSEntries() { + try { + const [status, result] = await this.uciGet('dhcp'); + const tbody = document.querySelector('#dns-entries-table tbody'); + + if (status !== 0 || !result || !result.values) { + this.renderEmptyTable(tbody, 3, 'No DNS entries'); + return; + } + + const domains = []; + for (const [section, config] of Object.entries(result.values)) { + if (config['.type'] === 'domain' && config.name && config.ip) { + domains.push({ section, name: config.name, ip: config.ip }); + } + } + + if (domains.length === 0) { + this.renderEmptyTable(tbody, 3, 'No DNS entries'); + return; + } + + tbody.innerHTML = domains + .map( + d => ` + + ${this.escapeHtml(d.name)} + ${this.escapeHtml(d.ip)} + + + + + + ` + ) + .join(''); + } catch (err) { + console.error('Failed to load DNS entries:', err); + this.showToast('Error', 'Failed to load DNS entries', 'error'); + } + } + + editDNSEntry(section, name, ip) { + document.getElementById('edit-dns-entry-section').value = section; + document.getElementById('edit-dns-hostname').value = name; + document.getElementById('edit-dns-ip').value = ip; + document.getElementById('dns-entry-modal').classList.remove('hidden'); + } + + async saveDNSEntry() { + try { + const section = document.getElementById('edit-dns-entry-section').value; + const hostname = document.getElementById('edit-dns-hostname').value.trim(); + const ip = document.getElementById('edit-dns-ip').value.trim(); + + if (!hostname || !ip) { + this.showToast('Error', 'Please fill all fields', 'error'); + return; + } + + const values = { name: hostname, ip }; + + if (section) { + await this.uciSet('dhcp', section, values); + } else { + await this.uciAdd('dhcp', 'domain', 'cfg_dns_' + Date.now(), values); + } + + await this.uciCommit('dhcp'); + await this.serviceReload('dnsmasq'); + + this.closeModal('dns-entry-modal'); + this.showToast('Success', 'DNS entry saved', 'success'); + await this.loadDNSEntries(); + } catch (err) { + console.error('Failed to save DNS entry:', err); + this.showToast('Error', 'Failed to save DNS entry', 'error'); + } + } + + async deleteDNSEntry(section) { + if (!confirm('Delete this DNS entry?')) return; + + try { + await this.uciDelete('dhcp', section); + await this.uciCommit('dhcp'); + await this.serviceReload('dnsmasq'); + + this.showToast('Success', 'DNS entry deleted', 'success'); + await this.loadDNSEntries(); + } catch (err) { + console.error('Failed to delete DNS entry:', err); + this.showToast('Error', 'Failed to delete DNS entry', 'error'); + } + } + + async loadHostsEntries() { + try { + const [status, result] = await this.ubusCall('file', 'read', { + path: '/etc/hosts' + }); + + const tbody = document.querySelector('#hosts-table tbody'); + + if (status !== 0 || !result || !result.data) { + tbody.innerHTML = + 'No host entries'; + return; + } + + const lines = result.data.split('\n'); + const hosts = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith('#')) continue; + + const parts = line.split(/\s+/); + if (parts.length >= 2) { + hosts.push({ + index: i, + ip: parts[0], + names: parts.slice(1).join(' ') + }); + } + } + + if (hosts.length === 0) { + tbody.innerHTML = + 'No host entries'; + return; + } + + tbody.innerHTML = hosts + .map( + h => ` + + ${this.escapeHtml(h.ip)} + ${this.escapeHtml(h.names)} + + + + + + ` + ) + .join(''); + } catch (err) { + console.error('Failed to load hosts entries:', err); + this.showToast('Error', 'Failed to load hosts entries', 'error'); + } + } + + editHostEntry(index, ip, names) { + document.getElementById('edit-host-entry-index').value = index; + document.getElementById('edit-host-ip').value = ip; + document.getElementById('edit-host-names').value = names; + document.getElementById('host-entry-modal').classList.remove('hidden'); + } + + async saveHostEntry() { + try { + const index = document.getElementById('edit-host-entry-index').value; + const ip = document.getElementById('edit-host-ip').value.trim(); + const names = document.getElementById('edit-host-names').value.trim(); + + if (!ip || !names) { + this.showToast('Error', 'Please fill all fields', 'error'); + return; + } + + const [status, result] = await this.ubusCall('file', 'read', { + path: '/etc/hosts' + }); + + let lines = result && result.data ? result.data.split('\n') : []; + + if (index !== '') { + lines[parseInt(index)] = `${ip}\t${names}`; + } else { + lines.push(`${ip}\t${names}`); + } + + await this.ubusCall('file', 'write', { + path: '/etc/hosts', + data: lines.join('\n') + }); + + document.getElementById('host-entry-modal').classList.add('hidden'); + this.showToast('Success', 'Host entry saved', 'success'); + await this.loadHostsEntries(); + } catch (err) { + console.error('Failed to save host entry:', err); + this.showToast('Error', 'Failed to save host entry', 'error'); + } + } + + async deleteHostEntry(index) { + if (!confirm('Delete this host entry?')) return; + + try { + const [status, result] = await this.ubusCall('file', 'read', { + path: '/etc/hosts' + }); + + if (status !== 0 || !result || !result.data) { + this.showToast('Error', 'Failed to read hosts file', 'error'); + return; + } + + const lines = result.data.split('\n'); + lines.splice(index, 1); + + await this.ubusCall('file', 'write', { + path: '/etc/hosts', + data: lines.join('\n') + }); + + this.showToast('Success', 'Host entry deleted', 'success'); + await this.loadHostsEntries(); + } catch (err) { + console.error('Failed to delete host entry:', err); + this.showToast('Error', 'Failed to delete host entry', 'error'); + } + } + + async loadDDNS() { + try { + const [status, config] = await this.uciGet('ddns'); + const tbody = document.querySelector('#ddns-table tbody'); + + if (!config || !config.values) { + this.renderEmptyTable(tbody, 6, 'No DDNS services configured'); + return; + } + + const rows = []; + for (const [section, data] of Object.entries(config.values)) { + if (data['.type'] === 'service') { + const name = data.name || section; + const hostname = data.lookup_host || 'N/A'; + const service = data.service_name || 'custom'; + const enabled = data.enabled === '1'; + const status = this.renderStatusBadge(enabled); + + rows.push(` + + ${this.escapeHtml(name)} + ${this.escapeHtml(hostname)} + ${this.escapeHtml(service)} + - + ${status} + + + + + + `); + } + } + + if (rows.length === 0) { + this.renderEmptyTable(tbody, 6, 'No DDNS services configured'); + } else { + tbody.innerHTML = rows.join(''); + } + } catch (err) { + console.error('Failed to load DDNS:', err); + this.renderEmptyTable(document.querySelector('#ddns-table tbody'), 6, 'Failed to load DDNS services'); + } + } + + async editDDNS(section) { + try { + const [status, data] = await this.uciGet('ddns', section); + + if (status === 0 && data && data.values) { + const values = data.values; + document.getElementById('edit-ddns-section').value = section; + document.getElementById('edit-ddns-name').value = values.name || ''; + document.getElementById('edit-ddns-service').value = values.service_name || 'dyndns.org'; + document.getElementById('edit-ddns-hostname').value = values.lookup_host || ''; + document.getElementById('edit-ddns-username').value = values.username || ''; + document.getElementById('edit-ddns-password').value = values.password || ''; + document.getElementById('edit-ddns-check-interval').value = values.check_interval || '10'; + document.getElementById('edit-ddns-enabled').value = values.enabled || '1'; + this.openModal('ddns-modal'); + } + } catch (err) { + console.error('Failed to load DDNS service:', err); + this.showToast('Error', 'Failed to load service', 'error'); + } + } + + async saveDDNS() { + try { + const section = document.getElementById('edit-ddns-section').value; + const name = document.getElementById('edit-ddns-name').value.trim(); + const service = document.getElementById('edit-ddns-service').value; + const hostname = document.getElementById('edit-ddns-hostname').value.trim(); + const username = document.getElementById('edit-ddns-username').value.trim(); + const password = document.getElementById('edit-ddns-password').value.trim(); + const interval = document.getElementById('edit-ddns-check-interval').value; + const enabled = document.getElementById('edit-ddns-enabled').value; + + if (!name || !hostname) { + this.showToast('Error', 'Please provide service name and hostname', 'error'); + return; + } + + const values = { + name, + service_name: service, + lookup_host: hostname, + enabled, + check_interval: interval, + use_ipv6: '0', + interface: 'wan' + }; + + if (username) values.username = username; + if (password) values.password = password; + + if (section) { + await this.uciSet('ddns', section, values); + } else { + await this.uciAdd('ddns', 'service', 'cfg_ddns_' + Date.now(), values); + } + + await this.uciCommit('ddns'); + await this.serviceReload('ddns'); + + this.closeModal('ddns-modal'); + this.showToast('Success', 'DDNS service saved', 'success'); + await this.loadDDNS(); + } catch (err) { + console.error('Failed to save DDNS service:', err); + this.showToast('Error', 'Failed to save service', 'error'); + } + } + + async deleteDDNS(section) { + if (!confirm('Delete this DDNS service?')) return; + + try { + await this.uciDelete('ddns', section); + await this.uciCommit('ddns'); + await this.serviceReload('ddns'); + + this.showToast('Success', 'DDNS service deleted', 'success'); + await this.loadDDNS(); + } catch (err) { + console.error('Failed to delete DDNS service:', err); + this.showToast('Error', 'Failed to delete service', 'error'); + } + } + + async loadQoS() { + await this.loadQoSConfig(); + await this.loadQoSRules(); + } + + async loadQoSConfig() { + try { + const [status, config] = await this.uciGet('qos'); + if (status === 0 && config && config.values && config.values.wan) { + const wan = config.values.wan; + document.getElementById('qos-enabled').value = wan.enabled || '0'; + document.getElementById('qos-download').value = wan.download || ''; + document.getElementById('qos-upload').value = wan.upload || ''; + } + } catch (err) { + console.error('Failed to load QoS config:', err); + } + } + + async saveQoSConfig() { + try { + const enabled = document.getElementById('qos-enabled').value; + const download = document.getElementById('qos-download').value; + const upload = document.getElementById('qos-upload').value; + + await this.uciSet('qos', 'wan', { enabled, download, upload, classgroup: 'Default' }); + await this.uciCommit('qos'); + if (enabled === '1') { + await this.serviceReload('qos'); + } + + this.showToast('Success', 'QoS configuration saved', 'success'); + } catch (err) { + console.error('Failed to save QoS config:', err); + this.showToast('Error', 'Failed to save configuration', 'error'); + } + } + + async loadQoSRules() { + try { + const [status, config] = await this.uciGet('qos'); + const tbody = document.querySelector('#qos-rules-table tbody'); + + if (!config || !config.values) { + this.renderEmptyTable(tbody, 6, 'No QoS rules'); + return; + } + + const rows = []; + for (const [section, data] of Object.entries(config.values)) { + if (data['.type'] === 'classify') { + rows.push(` + + ${this.escapeHtml(data.target || section)} + ${this.escapeHtml(data.priority || 'Normal')} + ${this.escapeHtml(data.proto || 'any')} + ${this.escapeHtml(data.ports || 'any')} + ${this.escapeHtml(data.srchost || 'any')} + + + + + + `); + } + } + + tbody.innerHTML = rows.length + ? rows.join('') + : 'No QoS rules'; + } catch (err) { + console.error('Failed to load QoS rules:', err); + this.renderEmptyTable(document.querySelector('#qos-rules-table tbody'), 6, 'Failed to load rules'); + } + } + + async editQoSRule(section) { + try { + const [status, data] = await this.uciGet('qos', section); + if (status === 0 && data && data.values) { + const v = data.values; + document.getElementById('edit-qos-rule-section').value = section; + document.getElementById('edit-qos-rule-name').value = v.target || ''; + document.getElementById('edit-qos-rule-priority').value = v.priority || 'Normal'; + document.getElementById('edit-qos-rule-proto').value = v.proto || ''; + document.getElementById('edit-qos-rule-ports').value = v.ports || ''; + document.getElementById('edit-qos-rule-srchost').value = v.srchost || ''; + this.openModal('qos-rule-modal'); + } + } catch (err) { + this.showToast('Error', 'Failed to load rule', 'error'); + } + } + + async saveQoSRule() { + try { + const section = document.getElementById('edit-qos-rule-section').value; + const name = document.getElementById('edit-qos-rule-name').value.trim(); + const priority = document.getElementById('edit-qos-rule-priority').value; + const proto = document.getElementById('edit-qos-rule-proto').value; + const ports = document.getElementById('edit-qos-rule-ports').value.trim(); + const srchost = document.getElementById('edit-qos-rule-srchost').value.trim(); + + if (!name) { + this.showToast('Error', 'Please provide a rule name', 'error'); + return; + } + + const values = { target: name, priority }; + if (proto) values.proto = proto; + if (ports) values.ports = ports; + if (srchost) values.srchost = srchost; + + if (section) { + await this.uciSet('qos', section, values); + } else { + await this.uciAdd('qos', 'classify', 'cfg_qos_' + Date.now(), values); + } + + await this.uciCommit('qos'); + await this.serviceReload('qos'); + + this.closeModal('qos-rule-modal'); + this.showToast('Success', 'QoS rule saved', 'success'); + await this.loadQoSRules(); + } catch (err) { + this.showToast('Error', 'Failed to save rule', 'error'); + } + } + + async deleteQoSRule(section) { + if (!confirm('Delete this QoS rule?')) return; + try { + await this.uciDelete('qos', section); + await this.uciCommit('qos'); + await this.serviceReload('qos'); + this.showToast('Success', 'QoS rule deleted', 'success'); + await this.loadQoSRules(); + } catch (err) { + this.showToast('Error', 'Failed to delete rule', 'error'); + } + } + + async loadWireGuard() { + await this.loadWireGuardConfig(); + await this.loadWireGuardPeers(); + } + + async loadWireGuardConfig() { + try { + const [statusNet, configNet] = await this.uciGet('network'); + const [statusWg, configWg] = await this.uciGet('network', 'wg0'); + + if (statusWg === 0 && configWg && configWg.values) { + const wg = configWg.values; + document.getElementById('wg-interface').value = 'wg0'; + document.getElementById('wg-port').value = wg.listen_port || '51820'; + document.getElementById('wg-private-key').value = wg.private_key || ''; + document.getElementById('wg-address').value = wg.addresses ? wg.addresses[0] : '10.0.0.1/24'; + document.getElementById('wg-enabled').value = wg.auto === '0' ? '0' : '1'; + + if (wg.private_key) { + const [status, result] = await this.ubusCall('file', 'exec', { + command: 'echo', + params: [wg.private_key, '|', 'wg', 'pubkey'] + }); + if (status === 0 && result.stdout) { + document.getElementById('wg-public-key').value = result.stdout.trim(); + } + } + } else { + document.getElementById('wg-interface').value = 'wg0'; + document.getElementById('wg-port').value = '51820'; + document.getElementById('wg-address').value = '10.0.0.1/24'; + document.getElementById('wg-enabled').value = '0'; + document.getElementById('wg-private-key').value = ''; + document.getElementById('wg-public-key').value = ''; + } + } catch (err) { + console.error('Failed to load WireGuard config:', err); + } + } + + async generateWireGuardKeys() { + try { + const [status, result] = await this.ubusCall('file', 'exec', { + command: 'wg', + params: ['genkey'] + }); + + if (status === 0 && result.stdout) { + const privateKey = result.stdout.trim(); + document.getElementById('wg-private-key').value = privateKey; + + const [pubStatus, pubResult] = await this.ubusCall('file', 'exec', { + command: 'echo', + params: [privateKey, '|', 'wg', 'pubkey'] + }); + + if (pubStatus === 0 && pubResult.stdout) { + document.getElementById('wg-public-key').value = pubResult.stdout.trim(); + } + + this.showToast('Success', 'Keys generated', 'success'); + } else { + this.showToast('Error', 'Failed to generate keys', 'error'); + } + } catch (err) { + console.error('Failed to generate WireGuard keys:', err); + this.showToast('Error', 'Failed to generate keys', 'error'); + } + } + + async saveWireGuardConfig() { + try { + const enabled = document.getElementById('wg-enabled').value; + const port = document.getElementById('wg-port').value; + const privateKey = document.getElementById('wg-private-key').value.trim(); + const address = document.getElementById('wg-address').value.trim(); + + if (!privateKey || !address) { + this.showToast('Error', 'Private key and address required', 'error'); + return; + } + + const values = { + proto: 'wireguard', + private_key: privateKey, + listen_port: port, + addresses: [address], + auto: enabled + }; + + await this.uciSet('network', 'wg0', values); + await this.uciCommit('network'); + + if (enabled === '1') { + await this.serviceReload('network'); + } + + this.showToast('Success', 'WireGuard configuration saved', 'success'); + } catch (err) { + console.error('Failed to save WireGuard config:', err); + this.showToast('Error', 'Failed to save configuration', 'error'); + } + } + + async loadWireGuardPeers() { + try { + const [status, config] = await this.uciGet('network'); + const tbody = document.querySelector('#wg-peers-table tbody'); + + if (!config || !config.values) { + this.renderEmptyTable(tbody, 6, 'No WireGuard peers configured'); + return; + } + + const rows = []; + for (const [section, data] of Object.entries(config.values)) { + if (data['.type'] === 'wireguard_wg0') { + const name = data.description || section; + const publicKey = data.public_key || 'N/A'; + const allowedIps = data.allowed_ips ? data.allowed_ips.join(', ') : 'N/A'; + const endpoint = data.endpoint_host ? `${data.endpoint_host}:${data.endpoint_port}` : 'N/A'; + const status = 'CONFIGURED'; + + rows.push(` + + ${this.escapeHtml(name)} + ${this.escapeHtml(publicKey.substring(0, 20))}... + ${this.escapeHtml(allowedIps)} + ${this.escapeHtml(endpoint)} + ${status} + + + + + + `); + } + } + + if (rows.length === 0) { + this.renderEmptyTable(tbody, 6, 'No WireGuard peers configured'); + } else { + tbody.innerHTML = rows.join(''); + } + } catch (err) { + console.error('Failed to load WireGuard peers:', err); + this.renderEmptyTable(document.querySelector('#wg-peers-table tbody'), 6, 'Failed to load peers'); + } + } + + async editWireGuardPeer(section) { + try { + const [status, config] = await this.uciGet('network', section); + if (status === 0 && config && config.values) { + const peer = config.values; + document.getElementById('edit-wg-peer-section').value = section; + document.getElementById('edit-wg-peer-name').value = peer.description || ''; + document.getElementById('edit-wg-peer-public-key').value = peer.public_key || ''; + document.getElementById('edit-wg-peer-allowed-ips').value = peer.allowed_ips + ? peer.allowed_ips.join(', ') + : ''; + document.getElementById('edit-wg-peer-keepalive').value = peer.persistent_keepalive || '25'; + document.getElementById('edit-wg-peer-preshared-key').value = peer.preshared_key || ''; + this.openModal('wg-peer-modal'); + } + } catch (err) { + console.error('Failed to load peer:', err); + this.showToast('Error', 'Failed to load peer', 'error'); + } + } + + async saveWireGuardPeer() { + try { + const section = document.getElementById('edit-wg-peer-section').value; + const name = document.getElementById('edit-wg-peer-name').value.trim(); + const publicKey = document.getElementById('edit-wg-peer-public-key').value.trim(); + const allowedIps = document.getElementById('edit-wg-peer-allowed-ips').value.trim(); + const keepalive = document.getElementById('edit-wg-peer-keepalive').value; + const presharedKey = document.getElementById('edit-wg-peer-preshared-key').value.trim(); + + if (!name || !publicKey || !allowedIps) { + this.showToast('Error', 'Name, public key, and allowed IPs required', 'error'); + return; + } + + const values = { + description: name, + public_key: publicKey, + allowed_ips: allowedIps.split(',').map(ip => ip.trim()), + persistent_keepalive: keepalive, + route_allowed_ips: '1' + }; + + if (presharedKey) { + values.preshared_key = presharedKey; + } + + if (section) { + await this.uciSet('network', section, values); + } else { + await this.uciAdd('network', 'wireguard_wg0', 'wgpeer_' + Date.now(), values); + } + + await this.uciCommit('network'); + await this.serviceReload('network'); + + this.closeModal('wg-peer-modal'); + this.showToast('Success', 'WireGuard peer saved', 'success'); + await this.loadWireGuardPeers(); + } catch (err) { + console.error('Failed to save peer:', err); + this.showToast('Error', 'Failed to save peer', 'error'); + } + } + + async deleteWireGuardPeer(section) { + if (!confirm('Delete this WireGuard peer?')) return; + try { + await this.uciDelete('network', section); + await this.uciCommit('network'); + await this.serviceReload('network'); + this.showToast('Success', 'Peer deleted', 'success'); + await this.loadWireGuardPeers(); + } catch (err) { + console.error('Failed to delete peer:', err); + this.showToast('Error', 'Failed to delete peer', 'error'); + } + } + + async loadServices() { + try { + const [status, result] = await this.ubusCall('file', 'exec', { + command: '/bin/ls', + params: ['/etc/init.d'] + }); + + const tbody = document.querySelector('#services-table tbody'); + + if (!result || !result.stdout) { + tbody.innerHTML = + 'Failed to load services'; + return; + } + + const services = result.stdout + .trim() + .split('\n') + .filter(s => s && !s.startsWith('README') && !s.includes('rcS') && !s.includes('rc.') && s !== 'boot') + .sort(); + + const rows = await Promise.all( + services.map(async service => { + const enabled = await this.isServiceEnabled(service); + const running = await this.isServiceRunning(service); + + const statusBadge = running + ? 'RUNNING' + : 'STOPPED'; + + const enabledBadge = enabled + ? 'YES' + : 'NO'; + + return ` + + ${this.escapeHtml(service)} + ${statusBadge} + ${enabledBadge} + + Start | + Stop | + Restart | + ${enabled ? 'Disable' : 'Enable'} + + + `; + }) + ); + + tbody.innerHTML = rows.join(''); + + document.querySelectorAll('#services-table .action-link').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const service = e.target.dataset.service; + const action = e.target.dataset.action; + this.manageService(service, action); + }); + }); + } catch (err) { + console.error('Failed to load services:', err); + const tbody = document.querySelector('#services-table tbody'); + tbody.innerHTML = + 'Failed to load services'; + } + } + + async isServiceEnabled(service) { + try { + const [status, result] = await this.ubusCall('file', 'exec', { + command: '/etc/init.d/' + service, + params: ['enabled'] + }); + return result && result.code === 0; + } catch { + return false; + } + } + + async isServiceRunning(service) { + try { + const [status, result] = await this.ubusCall('file', 'read', { + path: '/var/run/' + service + '.pid' + }); + return result && result.data; + } catch { + return false; + } + } + + async manageService(service, action) { + try { + this.showToast('Info', `${action}ing ${service}...`, 'info'); + + await this.ubusCall('file', 'exec', { + command: '/etc/init.d/' + service, + params: [action] + }); + + this.showToast('Success', `Service ${action} completed`, 'success'); + setTimeout(() => this.loadServices(), 2000); + } catch (err) { + console.error('Failed to manage service:', err); + this.showToast('Error', `Failed to ${action} service`, 'error'); + } + } + + async loadPackages() { + const tbody = document.querySelector('#packages-table tbody'); + try { + let status, result; + + const paths = ['/usr/lib/opkg/status', '/var/lib/opkg/status']; + + for (const path of paths) { + [status, result] = await this.ubusCall('file', 'read', { path }); + + if (status === 0 && result && result.data) { + const packages = []; + const entries = result.data.split('\n\n'); + for (const entry of entries) { + const nameMatch = entry.match(/^Package: (.+)$/m); + const versionMatch = entry.match(/^Version: (.+)$/m); + if (nameMatch && versionMatch) { + packages.push({ + name: nameMatch[1], + version: versionMatch[1] + }); + } + } + + if (packages.length === 0) continue; + + packages.sort((a, b) => a.name.localeCompare(b.name)); + + const rows = packages + .map( + pkg => ` + + ${this.escapeHtml(pkg.name)} + ${this.escapeHtml(pkg.version)} + + Remove + + + ` + ) + .join(''); + + tbody.innerHTML = rows; + + document.querySelectorAll('#packages-table .action-link-danger').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const pkg = e.target.dataset.package; + this.removePackage(pkg); + }); + }); + return; + } + } + + tbody.innerHTML = ` + + +
Package viewing requires ACL configuration. Run these commands:
+ + scp rpcd-acl.json root@192.168.1.1:/usr/share/rpcd/acl.d/moci.json
+ ssh root@192.168.1.1 "/etc/init.d/rpcd restart" +
+ + + `; + } catch (err) { + console.error('Failed to load packages:', err); + tbody.innerHTML = ` + + +
Package viewing requires ACL configuration. Run these commands:
+ + scp rpcd-acl.json root@192.168.1.1:/usr/share/rpcd/acl.d/moci.json
+ ssh root@192.168.1.1 "/etc/init.d/rpcd restart" +
+ + + `; + } + } + + async removePackage(pkg) { + if (!confirm(`Remove package ${pkg}? This may break dependencies.`)) return; + + try { + this.showToast('Info', `Removing ${pkg}...`, 'info'); + + await this.ubusCall('file', 'exec', { + command: '/bin/opkg', + params: ['remove', pkg] + }); + + this.showToast('Success', `Package ${pkg} removed`, 'success'); + setTimeout(() => this.loadPackages(), 2000); + } catch (err) { + console.error('Failed to remove package:', err); + this.showToast('Error', 'Failed to remove package', 'error'); + } + } + + async loadCronJobs() { + try { + const [status, result] = await this.ubusCall('file', 'read', { + path: '/etc/crontabs/root' + }); + + const tbody = document.querySelector('#cron-table tbody'); + + if (!result || !result.data) { + tbody.innerHTML = + 'No cron jobs configured'; + return; + } + + const crontab = result.data; + const lines = crontab.split('\n').filter(l => l.trim() && !l.startsWith('#')); + + if (lines.length === 0) { + tbody.innerHTML = + 'No cron jobs configured'; + return; + } + + const rows = lines + .map((line, idx) => { + const disabled = line.trim().startsWith('#'); + const actualLine = disabled ? line.trim().substring(1) : line; + const parts = actualLine.trim().split(/\s+/); + const schedule = parts.slice(0, 5).join(' '); + const command = parts.slice(5).join(' '); + + return ` + + ${this.escapeHtml(schedule)} + ${this.escapeHtml(command)} + ${disabled ? 'No' : 'Yes'} + + Edit + Delete + + + `; + }) + .join(''); + + tbody.innerHTML = rows; + + document.querySelectorAll('#cron-table .action-link').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const idx = parseInt(e.target.dataset.cronIdx); + this.openCronJob(idx); + }); + }); + + document.querySelectorAll('#cron-table .action-link-danger').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const idx = parseInt(e.target.dataset.cronIdx); + this.deleteCronJob(idx); + }); + }); + } catch (err) { + console.error('Failed to load cron jobs:', err); + document.querySelector('#cron-table tbody').innerHTML = + 'Failed to load cron jobs'; + } + } + + openCronJob(index = null) { + if (index !== null) { + this.ubusCall('file', 'read', { path: '/etc/crontabs/root' }).then(([status, result]) => { + if (result && result.data) { + const crontab = result.data; + const lines = crontab.split('\n').filter(l => l.trim() && !l.startsWith('#')); + const line = lines[index]; + + if (line) { + const disabled = line.trim().startsWith('#'); + const actualLine = disabled ? line.trim().substring(1) : line; + const parts = actualLine.trim().split(/\s+/); + + document.getElementById('edit-cron-minute').value = parts[0] || '*'; + document.getElementById('edit-cron-hour').value = parts[1] || '*'; + document.getElementById('edit-cron-day').value = parts[2] || '*'; + document.getElementById('edit-cron-month').value = parts[3] || '*'; + document.getElementById('edit-cron-weekday').value = parts[4] || '*'; + document.getElementById('edit-cron-command').value = parts.slice(5).join(' '); + document.getElementById('edit-cron-enabled').checked = !disabled; + document.getElementById('edit-cron-index').value = index; + } + } + }); + } else { + document.getElementById('edit-cron-minute').value = '*'; + document.getElementById('edit-cron-hour').value = '*'; + document.getElementById('edit-cron-day').value = '*'; + document.getElementById('edit-cron-month').value = '*'; + document.getElementById('edit-cron-weekday').value = '*'; + document.getElementById('edit-cron-command').value = ''; + document.getElementById('edit-cron-enabled').checked = true; + document.getElementById('edit-cron-index').value = ''; + } + + document.getElementById('cron-modal').classList.remove('hidden'); + } + + closeCronJob() { + document.getElementById('cron-modal').classList.add('hidden'); + } + + async saveCronJob() { + try { + const minute = document.getElementById('edit-cron-minute').value.trim() || '*'; + const hour = document.getElementById('edit-cron-hour').value.trim() || '*'; + const day = document.getElementById('edit-cron-day').value.trim() || '*'; + const month = document.getElementById('edit-cron-month').value.trim() || '*'; + const weekday = document.getElementById('edit-cron-weekday').value.trim() || '*'; + const command = document.getElementById('edit-cron-command').value.trim(); + const enabled = document.getElementById('edit-cron-enabled').checked; + const index = document.getElementById('edit-cron-index').value; + + if (!command) { + this.showToast('Error', 'Command is required', 'error'); + return; + } + + const [status, result] = await this.ubusCall('file', 'read', { + path: '/etc/crontabs/root' + }); + + let lines = []; + if (result && result.data) { + const crontab = result.data; + lines = crontab.split('\n').filter(l => l.trim() && !l.startsWith('#')); + } + + const cronLine = `${minute} ${hour} ${day} ${month} ${weekday} ${command}`; + const finalLine = enabled ? cronLine : `# ${cronLine}`; + + if (index !== '') { + lines[parseInt(index)] = finalLine; + } else { + lines.push(finalLine); + } + + const newCrontab = lines.join('\n') + '\n'; + + await this.ubusCall('file', 'write', { + path: '/etc/crontabs/root', + data: btoa(newCrontab), + base64: true + }); + + await this.ubusCall('file', 'exec', { + command: '/etc/init.d/cron', + params: ['restart'] + }); + + this.showToast('Success', 'Cron job saved', 'success'); + this.closeCronJob(); + setTimeout(() => this.loadCronJobs(), 1000); + } catch (err) { + console.error('Failed to save cron job:', err); + this.showToast('Error', 'Failed to save cron job', 'error'); + } + } + + async deleteCronJob(index) { + if (!confirm('Delete this cron job?')) return; + + try { + const [status, result] = await this.ubusCall('file', 'read', { + path: '/etc/crontabs/root' + }); + + if (!result || !result.data) { + this.showToast('Error', 'Failed to read crontab', 'error'); + return; + } + + const crontab = result.data; + let lines = crontab.split('\n').filter(l => l.trim() && !l.startsWith('#')); + lines.splice(index, 1); + + const newCrontab = lines.join('\n') + '\n'; + + await this.ubusCall('file', 'write', { + path: '/etc/crontabs/root', + data: btoa(newCrontab), + base64: true + }); + + await this.ubusCall('file', 'exec', { + command: '/etc/init.d/cron', + params: ['restart'] + }); + + this.showToast('Success', 'Cron job deleted', 'success'); + setTimeout(() => this.loadCronJobs(), 1000); + } catch (err) { + console.error('Failed to delete cron job:', err); + this.showToast('Error', 'Failed to delete cron job', 'error'); + } + } + + async loadSSHKeys() { + try { + const [status, result] = await this.ubusCall('file', 'read', { + path: '/etc/dropbear/authorized_keys' + }); + + const tbody = document.querySelector('#ssh-keys-table tbody'); + + if (!result || !result.data) { + tbody.innerHTML = + 'No SSH keys configured'; + return; + } + + const keys = result.data; + const lines = keys.split('\n').filter(l => l.trim() && !l.startsWith('#')); + + if (lines.length === 0) { + tbody.innerHTML = + 'No SSH keys configured'; + return; + } + + const rows = lines + .map((line, idx) => { + const parts = line.trim().split(/\s+/); + const type = parts[0]; + const key = parts[1]; + const comment = parts.slice(2).join(' ') || ''; + const keyPreview = key.substring(0, 40) + '...'; + + return ` + + ${this.escapeHtml(type)} + ${this.escapeHtml(keyPreview)} + ${this.escapeHtml(comment)} + + Delete + + + `; + }) + .join(''); + + tbody.innerHTML = rows; + + document.querySelectorAll('#ssh-keys-table .action-link-danger').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const idx = parseInt(e.target.dataset.keyIdx); + this.deleteSSHKey(idx); + }); + }); + } catch (err) { + console.error('Failed to load SSH keys:', err); + document.querySelector('#ssh-keys-table tbody').innerHTML = + 'Failed to load SSH keys'; + } + } + + openSSHKey() { + document.getElementById('ssh-key-paste-area').value = ''; + document.getElementById('parsed-keys-preview').style.display = 'none'; + document.getElementById('parsed-keys-list').innerHTML = ''; + document.getElementById('save-ssh-keys-btn').style.display = 'none'; + document.getElementById('ssh-key-modal').classList.remove('hidden'); + } + + closeSSHKey() { + document.getElementById('ssh-key-modal').classList.add('hidden'); + } + + parseSSHKeys() { + const pasteArea = document.getElementById('ssh-key-paste-area'); + const content = pasteArea.value.trim(); + + if (!content) { + this.showToast('Error', 'Please paste SSH keys', 'error'); + return; + } + + const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#')); + const validKeys = []; + const invalidLines = []; + + lines.forEach((line, idx) => { + const trimmed = line.trim(); + const match = trimmed.match( + /^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|ssh-dss)\s+([A-Za-z0-9+\/=]+)(\s+(.*))?$/ + ); + + if (match) { + const type = match[1]; + const key = match[2]; + const comment = match[4] || ''; + validKeys.push({ + type, + key, + comment, + full: trimmed + }); + } else { + invalidLines.push(idx + 1); + } + }); + + if (validKeys.length === 0) { + this.showToast('Error', 'No valid SSH keys found', 'error'); + return; + } + + const previewDiv = document.getElementById('parsed-keys-preview'); + const listDiv = document.getElementById('parsed-keys-list'); + + listDiv.innerHTML = validKeys + .map((key, idx) => { + const keyPreview = key.key.substring(0, 40) + '...'; + return ` +
+ +
+
+ ${this.escapeHtml(key.type)} + ${key.comment ? `${this.escapeHtml(key.comment)}` : 'no comment'} +
+
${this.escapeHtml(keyPreview)}
+
+
+ `; + }) + .join(''); + + if (invalidLines.length > 0) { + listDiv.innerHTML += ` +
+
SKIPPED INVALID LINES
+
Lines: ${invalidLines.join(', ')}
+
+ `; + } + + previewDiv.style.display = 'block'; + document.getElementById('save-ssh-keys-btn').style.display = 'inline-block'; + + this.parsedKeys = validKeys; + this.showToast('Success', `Parsed ${validKeys.length} valid key${validKeys.length > 1 ? 's' : ''}`, 'success'); + } + + async saveSSHKeys() { + try { + const selectedKeys = []; + this.parsedKeys.forEach((key, idx) => { + const checkbox = document.getElementById(`key-checkbox-${idx}`); + if (checkbox && checkbox.checked) { + selectedKeys.push(key.full); + } + }); + + if (selectedKeys.length === 0) { + this.showToast('Error', 'Please select at least one key to add', 'error'); + return; + } + + const [status, result] = await this.ubusCall('file', 'read', { + path: '/etc/dropbear/authorized_keys' + }); + + let lines = []; + if (result && result.data) { + const keys = result.data; + lines = keys.split('\n').filter(l => l.trim() && !l.startsWith('#')); + } + + lines.push(...selectedKeys); + + const newKeys = lines.join('\n') + '\n'; + + await this.ubusCall('file', 'write', { + path: '/etc/dropbear/authorized_keys', + data: btoa(newKeys), + base64: true, + mode: '0600' + }); + + this.showToast( + 'Success', + `Added ${selectedKeys.length} SSH key${selectedKeys.length > 1 ? 's' : ''}`, + 'success' + ); + this.closeSSHKey(); + setTimeout(() => this.loadSSHKeys(), 1000); + } catch (err) { + console.error('Failed to save SSH keys:', err); + this.showToast('Error', 'Failed to save SSH keys', 'error'); + } + } + + async deleteSSHKey(index) { + if (!confirm('Delete this SSH key?')) return; + + try { + const [status, result] = await this.ubusCall('file', 'read', { + path: '/etc/dropbear/authorized_keys' + }); + + if (!result || !result.data) { + this.showToast('Error', 'Failed to read keys', 'error'); + return; + } + + const keys = result.data; + let lines = keys.split('\n').filter(l => l.trim() && !l.startsWith('#')); + lines.splice(index, 1); + + const newKeys = lines.join('\n') + '\n'; + + await this.ubusCall('file', 'write', { + path: '/etc/dropbear/authorized_keys', + data: btoa(newKeys), + base64: true, + mode: '0600' + }); + + this.showToast('Success', 'SSH key deleted', 'success'); + setTimeout(() => this.loadSSHKeys(), 1000); + } catch (err) { + console.error('Failed to delete SSH key:', err); + this.showToast('Error', 'Failed to delete SSH key', 'error'); + } + } + + formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; + } + + async loadMountPoints() { + try { + const [status, result] = await this.ubusCall('luci', 'getMountPoints', {}); + + const tbody = document.querySelector('#mounts-table tbody'); + const chartsContainer = document.getElementById('storage-charts'); + + if (status !== 0 || !result || !result.result) { + tbody.innerHTML = + 'Failed to load mount points'; + chartsContainer.innerHTML = + '
Failed to load storage data
'; + return; + } + + const mounts = result.result; + + if (!mounts || mounts.length === 0) { + tbody.innerHTML = + 'No mount points found'; + chartsContainer.innerHTML = + '
No storage data available
'; + return; + } + + const charts = mounts + .map(m => { + const size = this.formatBytes(m.size); + const used = this.formatBytes(m.size - m.avail); + const available = this.formatBytes(m.avail); + const percent = m.size > 0 ? Math.round(((m.size - m.avail) / m.size) * 100) : 0; + + let barClass = ''; + if (percent > 90) barClass = 'critical'; + else if (percent > 75) barClass = 'warning'; + + return ` +
+
${this.escapeHtml(m.device)}
+
${this.escapeHtml(m.mount)}
+
${percent}%
+
+
+
+
+ ${used} used + ${available} free +
+
+ `; + }) + .join(''); + + chartsContainer.innerHTML = charts; + + const rows = mounts + .map(m => { + const size = this.formatBytes(m.size); + const used = this.formatBytes(m.size - m.avail); + const available = this.formatBytes(m.avail); + const percent = m.size > 0 ? Math.round(((m.size - m.avail) / m.size) * 100) : 0; + + return ` + + ${this.escapeHtml(m.device)} + ${this.escapeHtml(m.mount)} + auto + ${size} + ${used} (${percent}%) + ${available} + + `; + }) + .join(''); + + tbody.innerHTML = rows; + } catch (err) { + console.error('Failed to load mount points:', err); + document.querySelector('#mounts-table tbody').innerHTML = + 'Failed to load mount points'; + document.getElementById('storage-charts').innerHTML = + '
Failed to load storage data
'; + } + } + + async loadLEDs() { + try { + const [status, result] = await this.ubusCall('luci', 'getLEDs', {}); + + const tbody = document.querySelector('#led-table tbody'); + + if (status !== 0 || !result) { + tbody.innerHTML = + 'Failed to load LEDs'; + return; + } + + const leds = Object.entries(result); + + if (leds.length === 0) { + tbody.innerHTML = + 'No LEDs found'; + return; + } + + const rows = leds + .map(([name, info]) => { + const trigger = info.active_trigger || 'none'; + const brightness = info.brightness || 0; + const status = brightness > 0 ? 'ON' : 'OFF'; + + return ` + + ${this.escapeHtml(name)} + ${this.escapeHtml(trigger)} + + ${status} + + + `; + }) + .join(''); + + tbody.innerHTML = rows; + } catch (err) { + console.error('Failed to load LEDs:', err); + document.querySelector('#led-table tbody').innerHTML = + 'Failed to load LEDs'; + } + } + + async runPing() { + const host = document.getElementById('ping-host').value.trim(); + if (!host) { + this.showToast('Error', 'Please enter a hostname or IP address', 'error'); + return; + } + + const output = document.getElementById('ping-output'); + output.innerHTML = '
Running ping...
'; + + try { + const [status, result] = await this.ubusCall('file', 'exec', { + command: '/bin/ping', + params: ['-c', '4', host] + }); + + if (result && result.stdout) { + const lines = result.stdout.split('\n').filter(l => l.trim()); + output.innerHTML = lines.map(l => `
${this.escapeHtml(l)}
`).join(''); + } else { + output.innerHTML = '
Ping failed or permission denied
'; + } + } catch (err) { + output.innerHTML = '
Failed to execute ping
'; + } + } + + async generateBackup() { + try { + this.showToast('Info', 'Generating backup...', 'info'); + + const [status, result] = await this.ubusCall('file', 'exec', { + command: '/sbin/sysupgrade', + params: ['-b', '/tmp/backup.tar.gz'] + }); + + const [readStatus, backupData] = await this.ubusCall('file', 'read', { + path: '/tmp/backup.tar.gz', + base64: true + }); + + if (backupData && backupData.data) { + const blob = this.base64ToBlob(backupData.data, 'application/gzip'); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `openwrt-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`; + a.click(); + URL.revokeObjectURL(url); + + this.showToast('Success', 'Backup downloaded', 'success'); + } else { + this.showToast('Error', 'Failed to read backup file', 'error'); + } + } catch (err) { + console.error('Failed to generate backup:', err); + this.showToast('Error', 'Failed to generate backup', 'error'); + } + } + + base64ToBlob(base64, mimeType) { + const byteCharacters = atob(base64); + const byteArrays = []; + + for (let offset = 0; offset < byteCharacters.length; offset += 512) { + const slice = byteCharacters.slice(offset, offset + 512); + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + + return new Blob(byteArrays, { type: mimeType }); + } + + async resetToDefaults() { + if ( + !confirm('Reset all settings to factory defaults? This will ERASE ALL CONFIGURATION and reboot the router.') + ) + return; + if (!confirm('Are you ABSOLUTELY SURE? This cannot be undone!')) return; + + try { + this.showToast('Warning', 'Resetting to factory defaults...', 'error'); + + await this.ubusCall('file', 'exec', { + command: '/sbin/firstboot', + params: ['-y'] + }); + + await this.ubusCall('system', 'reboot', {}); + + this.showToast('Info', 'Router is resetting and rebooting...', 'info'); + setTimeout(() => this.logout(), 2000); + } catch (err) { + console.error('Failed to reset:', err); + this.showToast('Error', 'Failed to reset to defaults', 'error'); + } + } + + async changePassword() { + const newPassword = document.getElementById('new-password').value; + const confirmPassword = document.getElementById('confirm-password').value; + + if (!newPassword || !confirmPassword) { + this.showToast('Error', 'Please enter both password fields', 'error'); + return; + } + + if (newPassword !== confirmPassword) { + this.showToast('Error', 'Passwords do not match', 'error'); + return; + } + + if (newPassword.length < 6) { + this.showToast('Error', 'Password must be at least 6 characters', 'error'); + return; + } + + try { + await this.ubusCall('file', 'exec', { + command: '/bin/sh', + params: ['-c', `echo -e "${newPassword}\\n${newPassword}" | passwd root`] + }); + + this.showToast('Success', 'Password changed successfully', 'success'); + document.getElementById('new-password').value = ''; + document.getElementById('confirm-password').value = ''; + } catch (err) { + console.error('Failed to change password:', err); + this.showToast('Error', 'Failed to change password', 'error'); + } + } + + async saveGeneralSettings() { + try { + const hostname = document.getElementById('system-hostname').value; + const timezone = document.getElementById('system-timezone').value; + + if (!hostname) { + this.showToast('Error', 'Hostname is required', 'error'); + return; + } + + await this.ubusCall('uci', 'set', { + config: 'system', + section: '@system[0]', + values: { + hostname: hostname, + timezone: timezone || 'UTC' + } + }); + + await this.ubusCall('uci', 'commit', { + config: 'system' + }); + + await this.ubusCall('file', 'exec', { + command: '/etc/init.d/system', + params: ['reload'] + }); + + this.showToast('Success', 'Settings saved successfully', 'success'); + } catch (err) { + console.error('Failed to save settings:', err); + this.showToast('Error', 'Failed to save settings', 'error'); + } + } + + async runTraceroute() { + const host = document.getElementById('traceroute-host').value.trim(); + if (!host) { + this.showToast('Error', 'Please enter a hostname or IP address', 'error'); + return; + } + + const output = document.getElementById('traceroute-output'); + output.innerHTML = '
Running traceroute...
'; + + try { + const [status, result] = await this.ubusCall('file', 'exec', { + command: '/usr/bin/traceroute', + params: ['-m', '15', host] + }); + + if (result && result.stdout) { + const lines = result.stdout.split('\n').filter(l => l.trim()); + output.innerHTML = lines.map(l => `
${this.escapeHtml(l)}
`).join(''); + } else { + output.innerHTML = '
Traceroute failed or permission denied
'; + } + } catch (err) { + output.innerHTML = '
Failed to execute traceroute
'; + } + } + + async sendWakeOnLan() { + const mac = document.getElementById('wol-mac').value.trim(); + if (!mac) { + this.showToast('Error', 'Please enter a MAC address', 'error'); + return; + } + + const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/; + if (!macRegex.test(mac)) { + this.showToast('Error', 'Invalid MAC address format', 'error'); + return; + } + + const output = document.getElementById('wol-output'); + output.innerHTML = '
Sending WOL packet...
'; + + try { + const [status, result] = await this.ubusCall('file', 'exec', { + command: '/usr/bin/etherwake', + params: [mac] + }).catch(() => { + return this.ubusCall('file', 'exec', { + command: '/usr/bin/wol', + params: [mac] + }); + }); + + output.innerHTML = + '
WOL packet sent successfully to ' + + this.escapeHtml(mac) + + '
'; + this.showToast('Success', 'Wake-on-LAN packet sent', 'success'); + } catch (err) { + output.innerHTML = + '
Failed to send WOL packet. Make sure etherwake or wol package is installed.
'; + this.showToast('Error', 'Failed to send WOL packet', 'error'); + } + } + + initFirmwareUpgrade() { + const fileInput = document.getElementById('firmware-file'); + const fileUploadArea = document.getElementById('file-upload-area'); + const fileUploadText = document.getElementById('file-upload-text'); + const validateBtn = document.getElementById('validate-firmware-btn'); + const flashBtn = document.getElementById('flash-firmware-btn'); + + fileUploadArea.addEventListener('click', () => { + fileInput.click(); + }); + + fileUploadArea.addEventListener('dragover', e => { + e.preventDefault(); + fileUploadArea.style.borderColor = 'var(--neon-cyan)'; + fileUploadArea.style.background = 'rgba(0, 255, 255, 0.05)'; + }); + + fileUploadArea.addEventListener('dragleave', () => { + fileUploadArea.style.borderColor = 'var(--slate-border)'; + fileUploadArea.style.background = 'transparent'; + }); + + fileUploadArea.addEventListener('drop', e => { + e.preventDefault(); + fileUploadArea.style.borderColor = 'var(--slate-border)'; + fileUploadArea.style.background = 'transparent'; + + const files = e.dataTransfer.files; + if (files.length > 0) { + fileInput.files = files; + fileInput.dispatchEvent(new Event('change')); + } + }); + + fileInput.addEventListener('change', e => { + const file = e.target.files[0]; + if (file) { + fileUploadText.innerHTML = `Selected: ${this.escapeHtml(file.name)} (${(file.size / 1024 / 1024).toFixed(2)} MB)`; + validateBtn.disabled = false; + flashBtn.disabled = true; + document.getElementById('firmware-info').style.display = 'none'; + } + }); + + validateBtn.addEventListener('click', () => { + this.validateFirmware(); + }); + + flashBtn.addEventListener('click', () => { + this.flashFirmware(); + }); + } + + async validateFirmware() { + try { + const fileInput = document.getElementById('firmware-file'); + const file = fileInput.files[0]; + + if (!file) { + this.showToast('Error', 'Please select a firmware file', 'error'); + return; + } + + this.showToast('Info', 'Validating firmware...', 'info'); + + const reader = new FileReader(); + reader.onload = async e => { + try { + const arrayBuffer = e.target.result; + const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + + await this.ubusCall('file', 'write', { + path: '/tmp/firmware.bin', + data: base64, + base64: true + }); + + const [status, result] = await this.ubusCall('system', 'validate_firmware_image', { + path: '/tmp/firmware.bin' + }); + + const infoDiv = document.getElementById('firmware-info'); + const detailsDiv = document.getElementById('firmware-details'); + + if (status === 0 && result && result.valid) { + detailsDiv.innerHTML = ` +
✓ Firmware image is valid
+
+ ${ + result.tests + ? Object.entries(result.tests) + .map( + ([test, passed]) => + `
+ ${passed ? '✓' : '✗'} ${test} +
` + ) + .join('') + : '' + } +
+ `; + infoDiv.style.display = 'block'; + document.getElementById('flash-firmware-btn').disabled = false; + this.showToast('Success', 'Firmware validated successfully', 'success'); + } else { + detailsDiv.innerHTML = ` +
✗ Firmware image validation failed
+ ${ + result && result.tests + ? ` +
+ ${Object.entries(result.tests) + .map( + ([test, passed]) => + `
+ ${passed ? '✓' : '✗'} ${test} +
` + ) + .join('')} +
+ ` + : '' + } + `; + infoDiv.style.display = 'block'; + this.showToast('Error', 'Firmware validation failed', 'error'); + } + } catch (err) { + console.error('Firmware validation error:', err); + this.showToast('Error', 'Failed to validate firmware', 'error'); + } + }; + + reader.readAsArrayBuffer(file); + } catch (err) { + console.error('Failed to validate firmware:', err); + this.showToast('Error', 'Failed to validate firmware', 'error'); + } + } + + async flashFirmware() { + const keepSettings = document.getElementById('keep-settings').checked; + + if ( + !confirm( + '⚠ WARNING: This will upgrade the firmware and reboot the device.\n\n' + + (keepSettings ? 'Settings will be preserved.' : 'Settings will be reset to defaults.') + + '\n\nDo you want to continue?' + ) + ) { + return; + } + + try { + const progressDiv = document.getElementById('upgrade-progress'); + const statusDiv = document.getElementById('upgrade-status'); + + progressDiv.style.display = 'block'; + statusDiv.innerHTML = '
Starting firmware upgrade...
'; + + document.getElementById('validate-firmware-btn').disabled = true; + document.getElementById('flash-firmware-btn').disabled = true; + document.getElementById('firmware-file').disabled = true; + + const command = keepSettings + ? '/sbin/sysupgrade /tmp/firmware.bin' + : '/sbin/sysupgrade -n /tmp/firmware.bin'; + + statusDiv.innerHTML += '
Flashing firmware...
'; + statusDiv.innerHTML += + '
This may take several minutes. Do not power off the device.
'; + + await this.ubusCall('file', 'exec', { + command: '/sbin/sysupgrade', + params: keepSettings ? ['/tmp/firmware.bin'] : ['-n', '/tmp/firmware.bin'] + }); + + statusDiv.innerHTML += + '
✓ Firmware flashed successfully
'; + statusDiv.innerHTML += '
Device is rebooting...
'; + statusDiv.innerHTML += + '
The device will be available in approximately 2-3 minutes.
'; + + this.showToast('Success', 'Firmware upgrade initiated', 'success'); + + setTimeout(() => { + statusDiv.innerHTML += + '
Waiting for device to come back online...
'; + }, 5000); + } catch (err) { + console.error('Failed to flash firmware:', err); + document.getElementById('upgrade-status').innerHTML += + '
✗ Firmware upgrade failed: ' + + this.escapeHtml(err.message) + + '
'; + this.showToast('Error', 'Failed to flash firmware', 'error'); + } + } +} + +window.app = new OpenWrtApp(); diff --git a/moci/index.html b/moci/index.html index 3583b22..f5aa135 100644 --- a/moci/index.html +++ b/moci/index.html @@ -13,11 +13,11 @@
-