Initial commit
This commit is contained in:
@@ -1,2 +1,398 @@
|
|||||||
|
<<<<<<< HEAD
|
||||||
# wol-dashboard
|
# wol-dashboard
|
||||||
|
|
||||||
|
=======
|
||||||
|
# ⚡ Wake-on-LAN Dashboard
|
||||||
|
|
||||||
|
A modern, self-hosted web dashboard to remotely power on PCs via Magic Packet.
|
||||||
|
Built with Python (Flask) and a responsive HTML/CSS/JS frontend — works on desktop and mobile.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Features
|
||||||
|
|
||||||
|
- ⚡ Wake up PCs remotely via Magic Packet (Wake-on-LAN)
|
||||||
|
- 🟢 Real-time Online/Offline status via Ping
|
||||||
|
- 🔍 Automatic network scan to discover devices (nmap)
|
||||||
|
- ➕ Manually add devices (Name, MAC, IP)
|
||||||
|
- 🗑 Remove devices from the list
|
||||||
|
- 📱 Fully responsive — works on mobile and desktop
|
||||||
|
- 🔄 Auto-refresh status every 30 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂 Project Structure
|
||||||
|
wol-dashboard/
|
||||||
|
├── app.py # Flask backend for Windows
|
||||||
|
├── app_linux.py # Flask backend for Linux
|
||||||
|
├── devices.json # Device database (auto-created on first save)
|
||||||
|
├── README.md
|
||||||
|
└── templates/
|
||||||
|
└── index.html # Web frontend (shared for both platforms)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Installation — Windows
|
||||||
|
|
||||||
|
### Step 1 — Install Python
|
||||||
|
|
||||||
|
Open **PowerShell or CMD as Administrator** and run:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
winget install -e --id Python.Python.3.13
|
||||||
|
```
|
||||||
|
|
||||||
|
Close and reopen CMD after installation. Verify:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
python --version
|
||||||
|
pip --version
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** If `python` opens the Microsoft Store instead, go to:
|
||||||
|
> **Settings → Apps → Advanced App Settings → App Execution Aliases**
|
||||||
|
> and disable `python.exe` and `python3.exe`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Install nmap
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
winget install -e --id Insecure.Nmap
|
||||||
|
```
|
||||||
|
|
||||||
|
Close and reopen CMD. Verify:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
nmap --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Set up the project
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
cd C:\Users\<YourName>\Desktop\wol-dashboard
|
||||||
|
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
|
||||||
|
pip install flask wakeonlan
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Create project files
|
||||||
|
|
||||||
|
Make sure your folder contains these files:
|
||||||
|
wol-dashboard/
|
||||||
|
├── app.py
|
||||||
|
├── devices.json ← optional, auto-created
|
||||||
|
└── templates/
|
||||||
|
└── index.html
|
||||||
|
|
||||||
|
Create the templates folder if it doesn't exist:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
mkdir templates
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — Start the server
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
venv\Scripts\activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
Running on http://0.0.0.0:5000
|
||||||
|
|
||||||
|
|
||||||
|
Open in your browser: **http://localhost:5000**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Optional — Allow port 5000 through Windows Firewall
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
netsh advfirewall firewall add rule name="Flask WOL" dir=in action=allow protocol=TCP localport=5000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐧 Installation — Linux (Debian / Ubuntu)
|
||||||
|
|
||||||
|
### Step 1 — Install system dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install python3 python3-venv python3-full nmap -y
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 --version
|
||||||
|
nmap --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Set up the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/DEV/wol-dashboard
|
||||||
|
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
pip install flask wakeonlan
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Grant nmap network permissions (once)
|
||||||
|
|
||||||
|
nmap needs elevated privileges for ARP scans. Instead of running as root, grant capabilities once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo setcap cap_net_raw,cap_net_admin+eip $(which nmap)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Create project files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p templates
|
||||||
|
# Place app_linux.py and templates/index.html in the folder
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — Start the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python app_linux.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
Running on http://0.0.0.0:5000
|
||||||
|
|
||||||
|
|
||||||
|
Open in your browser: **http://localhost:5000**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Optional — Auto-start on boot with systemd
|
||||||
|
|
||||||
|
Create a service file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/wol-dashboard.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the following (adjust paths):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Wake-on-LAN Dashboard
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=YOUR_USERNAME
|
||||||
|
WorkingDirectory=/home/YOUR_USERNAME/DEV/wol-dashboard
|
||||||
|
ExecStart=/home/YOUR_USERNAME/DEV/wol-dashboard/venv/bin/python app_linux.py
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable wol-dashboard
|
||||||
|
sudo systemctl start wol-dashboard
|
||||||
|
sudo systemctl status wol-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Accessing from Phone or Other Devices
|
||||||
|
|
||||||
|
1. Find your server IP:
|
||||||
|
- **Windows:** Open CMD → `ipconfig` → look for **IPv4 Address**
|
||||||
|
- **Linux:** Run `hostname -I`
|
||||||
|
|
||||||
|
2. Open on any device in the same network:
|
||||||
|
http://192.168.x.x:5000
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Wake-on-LAN Setup on Target PCs
|
||||||
|
|
||||||
|
For a PC to be woken up, it must be configured correctly.
|
||||||
|
|
||||||
|
### BIOS / UEFI
|
||||||
|
- Enter BIOS on boot (usually `DEL`, `F2`, or `F12`)
|
||||||
|
- Find and enable: `Wake-on-LAN`, `Power On By PCI-E`, or `Resume By LAN`
|
||||||
|
|
||||||
|
### Windows Target PC
|
||||||
|
1. Open **Device Manager**
|
||||||
|
2. Expand **Network Adapters** → right-click your adapter → **Properties**
|
||||||
|
3. Tab **Power Management**:
|
||||||
|
- ✅ Allow this device to wake the computer
|
||||||
|
4. Tab **Advanced**:
|
||||||
|
- Set `Wake on Magic Packet` → **Enabled**
|
||||||
|
|
||||||
|
### Linux Target PC
|
||||||
|
```bash
|
||||||
|
sudo apt install ethtool
|
||||||
|
|
||||||
|
# Check current WoL status
|
||||||
|
sudo ethtool eth0 | grep Wake
|
||||||
|
|
||||||
|
# Enable WoL (replace eth0 with your adapter name)
|
||||||
|
sudo ethtool -s eth0 wol g
|
||||||
|
```
|
||||||
|
|
||||||
|
To make it permanent, add to `/etc/rc.local` or create a systemd service.
|
||||||
|
|
||||||
|
Find your adapter name with:
|
||||||
|
```bash
|
||||||
|
ip link show
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄 devices.json
|
||||||
|
|
||||||
|
Devices are stored in `devices.json` in the project root. It is created automatically when you save the first device via the UI.
|
||||||
|
|
||||||
|
You can also edit it manually:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Gaming-PC",
|
||||||
|
"mac": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"ip": "192.168.1.100"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Work Laptop",
|
||||||
|
"mac": "11:22:33:44:55:66",
|
||||||
|
"ip": "192.168.1.105"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|--------|----------|--------------------------------------------------|
|
||||||
|
| `name` | ✅ | Display name shown on the dashboard card |
|
||||||
|
| `mac` | ✅ | MAC address — colons or dashes both accepted |
|
||||||
|
| `ip` | ❌ | IP address — required for online status ping |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Daily Usage
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
```cmd
|
||||||
|
cd C:\Users\<YourName>\Desktop\wol-dashboard
|
||||||
|
venv\Scripts\activate
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
```bash
|
||||||
|
cd ~/DEV/wol-dashboard
|
||||||
|
source venv/bin/activate
|
||||||
|
python app_linux.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or create a startup script `start.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
cd ~/DEV/wol-dashboard
|
||||||
|
source venv/bin/activate
|
||||||
|
python app_linux.py
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x start.sh
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Platform Differences
|
||||||
|
|
||||||
|
| Feature | `app.py` (Windows) | `app_linux.py` (Linux) |
|
||||||
|
|----------------------|------------------------|------------------------------|
|
||||||
|
| Ping command | `ping -n 1 -w 500` | `ping -c 1 -W 1` |
|
||||||
|
| ARP command | `arp -a` | `arp -n` |
|
||||||
|
| MAC format in ARP | `AA-BB-CC-DD-EE-FF` | `aa:bb:cc:dd:ee:ff` |
|
||||||
|
| nmap line endings | `\r\n` | `\n` |
|
||||||
|
| Elevated rights | Run CMD as Admin | `setcap` once |
|
||||||
|
| Auto-start | Task Scheduler | systemd service |
|
||||||
|
| `index.html` | ✅ Shared | ✅ Shared |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Dependencies
|
||||||
|
|
||||||
|
| Package | Install via | Purpose |
|
||||||
|
|-------------|--------------|--------------------------|
|
||||||
|
| `flask` | pip | Web framework |
|
||||||
|
| `wakeonlan` | pip | Send Magic Packets |
|
||||||
|
| `nmap` | System | Network device scanning |
|
||||||
|
|
||||||
|
Install Python packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install flask wakeonlan
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Security Notice
|
||||||
|
|
||||||
|
This dashboard is designed for **trusted local networks only**.
|
||||||
|
It has no authentication built in. Do not expose port 5000 to the internet without:
|
||||||
|
- A reverse proxy (e.g. nginx) with HTTPS
|
||||||
|
- HTTP Basic Auth or a login system
|
||||||
|
- A VPN (e.g. WireGuard, Tailscale)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|----------------------------------|--------------------------------------------------------------------------|
|
||||||
|
| `python` opens Microsoft Store | Disable App Execution Aliases in Windows Settings |
|
||||||
|
| `nmap` not found | Install nmap and restart terminal |
|
||||||
|
| `venv\Scripts\activate` fails | Run `python -m venv venv` first |
|
||||||
|
| PC does not wake up | Enable WoL in BIOS and network adapter settings |
|
||||||
|
| Status always shows Offline | Make sure IP is correct and target allows ping (check firewall) |
|
||||||
|
| Port 5000 blocked on Windows | Add firewall rule: `netsh advfirewall firewall add rule name="Flask WOL" dir=in action=allow protocol=TCP localport=5000` |
|
||||||
|
| Scan finds no devices | Run `setcap` on Linux or start CMD as Admin on Windows |
|
||||||
|
| Page not loading | Check Flask is running and no error in terminal |
|
||||||
|
| `externally-managed-environment` | Use a virtual environment: `python3 -m venv venv` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License — free to use, modify and distribute.
|
||||||
|
>>>>>>> edfb358 (Initial commit)
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
from wakeonlan import send_magic_packet
|
||||||
|
import json, os, subprocess, re, socket, concurrent.futures
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
DEVICES_FILE = "devices.json"
|
||||||
|
|
||||||
|
def load_devices():
|
||||||
|
if os.path.exists(DEVICES_FILE):
|
||||||
|
with open(DEVICES_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_devices(devices):
|
||||||
|
with open(DEVICES_FILE, "w") as f:
|
||||||
|
json.dump(devices, f, indent=2)
|
||||||
|
|
||||||
|
def ping(ip):
|
||||||
|
"""Ping a device to check if it is online (Linux)."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ping", "-c", "1", "-W", "1", ip],
|
||||||
|
capture_output=True, timeout=3
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/wake/<mac>", methods=["POST"])
|
||||||
|
def wake(mac):
|
||||||
|
try:
|
||||||
|
send_magic_packet(mac)
|
||||||
|
return jsonify({"status": "success", "message": "Magic Packet sent!"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "message": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/devices", methods=["GET"])
|
||||||
|
def get_devices():
|
||||||
|
return jsonify(load_devices())
|
||||||
|
|
||||||
|
@app.route("/devices", methods=["POST"])
|
||||||
|
def add_device():
|
||||||
|
data = request.json
|
||||||
|
devices = load_devices()
|
||||||
|
devices.append({
|
||||||
|
"name": data["name"],
|
||||||
|
"mac": data["mac"],
|
||||||
|
"ip": data.get("ip", "")
|
||||||
|
})
|
||||||
|
save_devices(devices)
|
||||||
|
return jsonify({"status": "success"})
|
||||||
|
|
||||||
|
@app.route("/devices/<int:idx>", methods=["DELETE"])
|
||||||
|
def delete_device(idx):
|
||||||
|
devices = load_devices()
|
||||||
|
if 0 <= idx < len(devices):
|
||||||
|
devices.pop(idx)
|
||||||
|
save_devices(devices)
|
||||||
|
return jsonify({"status": "success"})
|
||||||
|
return jsonify({"status": "error"}), 404
|
||||||
|
|
||||||
|
@app.route("/status", methods=["GET"])
|
||||||
|
def get_status():
|
||||||
|
"""Check online status of all devices in parallel."""
|
||||||
|
devices = load_devices()
|
||||||
|
def check(d):
|
||||||
|
ip = d.get("ip", "")
|
||||||
|
if not ip:
|
||||||
|
return None
|
||||||
|
return ping(ip)
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
results = list(executor.map(check, devices))
|
||||||
|
return jsonify(results)
|
||||||
|
|
||||||
|
@app.route("/scan", methods=["POST"])
|
||||||
|
def scan_network():
|
||||||
|
"""Scan the local network for active devices using nmap."""
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
local_ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
subnet = ".".join(local_ip.split(".")[:3]) + ".0/24"
|
||||||
|
|
||||||
|
result = subprocess.check_output(
|
||||||
|
f"nmap -sn {subnet} --system-dns",
|
||||||
|
shell=True, stderr=subprocess.DEVNULL
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
arp_output = subprocess.check_output("arp -n", shell=True).decode()
|
||||||
|
|
||||||
|
found = []
|
||||||
|
nmap_hosts = re.findall(r"Nmap scan report for (.+)\nHost is up", result)
|
||||||
|
|
||||||
|
for host in nmap_hosts:
|
||||||
|
ip_match = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)", host)
|
||||||
|
ip = ip_match.group(1) if ip_match else host.strip()
|
||||||
|
name = re.sub(r"\s*\(.*?\)", "", host).strip()
|
||||||
|
|
||||||
|
arp_match = re.search(
|
||||||
|
rf"{re.escape(ip)}\s+\S+\s+([0-9a-f]{{2}}(?::[0-9a-f]{{2}}){{5}})\s",
|
||||||
|
arp_output, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if arp_match:
|
||||||
|
mac = arp_match.group(1).upper()
|
||||||
|
found.append({
|
||||||
|
"name": name if name != ip else f"Device ({ip})",
|
||||||
|
"mac": mac,
|
||||||
|
"ip": ip
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"status": "success", "devices": found})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "message": str(e)}), 500
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
from wakeonlan import send_magic_packet
|
||||||
|
import json, os, subprocess, re, socket, concurrent.futures
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
DEVICES_FILE = "devices.json"
|
||||||
|
|
||||||
|
def load_devices():
|
||||||
|
if os.path.exists(DEVICES_FILE):
|
||||||
|
with open(DEVICES_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_devices(devices):
|
||||||
|
with open(DEVICES_FILE, "w") as f:
|
||||||
|
json.dump(devices, f, indent=2)
|
||||||
|
|
||||||
|
def ping(ip):
|
||||||
|
"""Ping a device to check if it is online (Windows)."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ping", "-n", "1", "-w", "500", ip],
|
||||||
|
capture_output=True, timeout=2
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/wake/<mac>", methods=["POST"])
|
||||||
|
def wake(mac):
|
||||||
|
try:
|
||||||
|
send_magic_packet(mac)
|
||||||
|
return jsonify({"status": "success", "message": "Magic Packet sent!"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "message": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/devices", methods=["GET"])
|
||||||
|
def get_devices():
|
||||||
|
return jsonify(load_devices())
|
||||||
|
|
||||||
|
@app.route("/devices", methods=["POST"])
|
||||||
|
def add_device():
|
||||||
|
data = request.json
|
||||||
|
devices = load_devices()
|
||||||
|
devices.append({
|
||||||
|
"name": data["name"],
|
||||||
|
"mac": data["mac"],
|
||||||
|
"ip": data.get("ip", "")
|
||||||
|
})
|
||||||
|
save_devices(devices)
|
||||||
|
return jsonify({"status": "success"})
|
||||||
|
|
||||||
|
@app.route("/devices/<int:idx>", methods=["DELETE"])
|
||||||
|
def delete_device(idx):
|
||||||
|
devices = load_devices()
|
||||||
|
if 0 <= idx < len(devices):
|
||||||
|
devices.pop(idx)
|
||||||
|
save_devices(devices)
|
||||||
|
return jsonify({"status": "success"})
|
||||||
|
return jsonify({"status": "error"}), 404
|
||||||
|
|
||||||
|
@app.route("/status", methods=["GET"])
|
||||||
|
def get_status():
|
||||||
|
"""Check online status of all devices in parallel."""
|
||||||
|
devices = load_devices()
|
||||||
|
def check(d):
|
||||||
|
ip = d.get("ip", "")
|
||||||
|
if not ip:
|
||||||
|
return None
|
||||||
|
return ping(ip)
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
results = list(executor.map(check, devices))
|
||||||
|
return jsonify(results)
|
||||||
|
|
||||||
|
@app.route("/scan", methods=["POST"])
|
||||||
|
def scan_network():
|
||||||
|
"""Scan the local network for active devices using nmap."""
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
local_ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
subnet = ".".join(local_ip.split(".")[:3]) + ".0/24"
|
||||||
|
|
||||||
|
result = subprocess.check_output(
|
||||||
|
f"nmap -sn {subnet}",
|
||||||
|
shell=True, stderr=subprocess.DEVNULL
|
||||||
|
).decode(errors="ignore")
|
||||||
|
|
||||||
|
arp_output = subprocess.check_output("arp -a", shell=True).decode(errors="ignore")
|
||||||
|
|
||||||
|
found = []
|
||||||
|
nmap_hosts = re.findall(r"Nmap scan report for (.+)\r?\nHost is up", result)
|
||||||
|
|
||||||
|
for host in nmap_hosts:
|
||||||
|
ip_match = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)", host)
|
||||||
|
ip = ip_match.group(1) if ip_match else host.strip()
|
||||||
|
name = re.sub(r"\s*\(.*?\)", "", host).strip()
|
||||||
|
|
||||||
|
arp_match = re.search(
|
||||||
|
rf"{re.escape(ip)}\s+([0-9a-f]{{2}}(?:[:-][0-9a-f]{{2}}){{5}})",
|
||||||
|
arp_output, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if arp_match:
|
||||||
|
mac = arp_match.group(1).replace("-", ":").upper()
|
||||||
|
found.append({
|
||||||
|
"name": name if name != ip else f"Device ({ip})",
|
||||||
|
"mac": mac,
|
||||||
|
"ip": ip
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"status": "success", "devices": found})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "message": str(e)}), 500
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WoL Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #080d14;
|
||||||
|
--surface: #111827;
|
||||||
|
--surface2: #1a2235;
|
||||||
|
--border: #1e2d45;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent2: #6366f1;
|
||||||
|
--success: #10b981;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--muted: #64748b;
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
.header { text-align: center; padding: 2rem 0 2rem; }
|
||||||
|
.header .logo { font-size: 2.8rem; margin-bottom: 0.5rem; }
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.6rem; font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||||
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text; margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.header p { color: var(--muted); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex; justify-content: center; gap: 0.8rem;
|
||||||
|
margin-bottom: 2rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stat-chip {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 999px; padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.82rem; color: var(--muted);
|
||||||
|
}
|
||||||
|
.stat-chip span { font-weight: 700; }
|
||||||
|
.online-count { color: var(--success) !important; }
|
||||||
|
.offline-count { color: var(--danger) !important; }
|
||||||
|
.total-count { color: var(--accent) !important; }
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||||
|
gap: 1rem; max-width: 1000px; margin: 0 auto 2rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 1.3rem;
|
||||||
|
display: flex; flex-direction: column; gap: 0.8rem;
|
||||||
|
transition: all 0.25s; position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.card::before {
|
||||||
|
content: ''; position: absolute; top: 0; left: 0; right: 0;
|
||||||
|
height: 2px; background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||||
|
opacity: 0; transition: opacity 0.25s;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 8px 30px rgba(59,130,246,0.1); }
|
||||||
|
.card:hover::before { opacity: 1; }
|
||||||
|
.card.is-online { border-color: rgba(16,185,129,0.35); }
|
||||||
|
.card.is-online::before { background: linear-gradient(90deg, var(--success), #059669); opacity: 1; }
|
||||||
|
|
||||||
|
.card-top { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
.card-icon {
|
||||||
|
width: 42px; height: 42px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
border-radius: 10px; display: flex; align-items: center;
|
||||||
|
justify-content: center; font-size: 1.2rem; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.card-icon.online { background: linear-gradient(135deg, var(--success), #059669); }
|
||||||
|
|
||||||
|
.card-actions { display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
.status-dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: var(--muted); flex-shrink: 0; transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.status-dot.online { background: var(--success); box-shadow: 0 0 6px var(--success); animation: pulse 2s infinite; }
|
||||||
|
.status-dot.offline { background: var(--danger); }
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 4px var(--success); }
|
||||||
|
50% { box-shadow: 0 0 10px var(--success); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-del {
|
||||||
|
background: none; border: none; color: var(--muted);
|
||||||
|
cursor: pointer; font-size: 1rem; padding: 4px;
|
||||||
|
border-radius: 6px; transition: all 0.2s; line-height: 1;
|
||||||
|
}
|
||||||
|
.btn-del:hover { color: var(--danger); background: rgba(239,68,68,0.1); }
|
||||||
|
|
||||||
|
.card-name { font-size: 1rem; font-weight: 700; }
|
||||||
|
.card-mac {
|
||||||
|
font-family: 'Courier New', monospace; font-size: 0.78rem;
|
||||||
|
color: var(--muted); background: var(--surface2);
|
||||||
|
padding: 0.35rem 0.65rem; border-radius: 8px;
|
||||||
|
}
|
||||||
|
.card-ip { font-size: 0.78rem; color: var(--muted); font-family: monospace; }
|
||||||
|
.status-label { font-size: 0.78rem; font-weight: 600; }
|
||||||
|
.status-label.online { color: var(--success); }
|
||||||
|
.status-label.offline { color: var(--danger); }
|
||||||
|
.status-label.unknown { color: var(--muted); }
|
||||||
|
|
||||||
|
.btn-wake {
|
||||||
|
width: 100%; padding: 0.75rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
color: white; border: none; border-radius: 10px;
|
||||||
|
font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
||||||
|
transition: all 0.2s; display: flex; align-items: center;
|
||||||
|
justify-content: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-wake:hover { opacity: 0.85; transform: scale(0.98); }
|
||||||
|
.btn-wake:active { transform: scale(0.95); }
|
||||||
|
|
||||||
|
.feedback-msg {
|
||||||
|
font-size: 0.78rem; text-align: center;
|
||||||
|
min-height: 1rem; padding: 0.2rem;
|
||||||
|
border-radius: 6px; transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.success { color: var(--success); background: rgba(16,185,129,0.08); padding: 0.3rem 0.5rem; }
|
||||||
|
.error { color: var(--danger); background: rgba(239,68,68,0.08); padding: 0.3rem 0.5rem; }
|
||||||
|
|
||||||
|
.empty-state { grid-column: 1/-1; text-align: center; padding: 3rem 1rem; color: var(--muted); }
|
||||||
|
.empty-state .empty-icon { font-size: 3rem; margin-bottom: 0.8rem; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
max-width: 480px; margin: 0 auto 1.2rem;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
padding: 1rem 1.3rem; display: flex; align-items: center;
|
||||||
|
gap: 0.6rem; cursor: pointer; user-select: none; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.panel-header:hover { background: var(--surface2); }
|
||||||
|
.panel-header h3 { font-size: 0.95rem; font-weight: 600; flex: 1; }
|
||||||
|
.panel-header .chevron { color: var(--muted); transition: transform 0.3s; }
|
||||||
|
.panel-header.open .chevron { transform: rotate(180deg); }
|
||||||
|
.panel-body { padding: 0 1.3rem 1.3rem; display: none; }
|
||||||
|
.panel-body.open { display: block; }
|
||||||
|
|
||||||
|
.panel-body input {
|
||||||
|
width: 100%; padding: 0.7rem 0.9rem;
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; color: var(--text); font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.65rem; outline: none; transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.panel-body input:focus { border-color: var(--accent); }
|
||||||
|
.panel-body input::placeholder { color: var(--muted); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%; padding: 0.75rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
color: white; border: none; border-radius: 10px;
|
||||||
|
font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { opacity: 0.85; }
|
||||||
|
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.scan-hint { font-size: 0.8rem; color: var(--muted); margin-bottom: 1rem; }
|
||||||
|
.scan-status { font-size: 0.83rem; margin: 0.8rem 0 0.3rem; }
|
||||||
|
.scan-item {
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 0.75rem 1rem; margin-top: 0.6rem;
|
||||||
|
display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.scan-item-name { font-weight: 600; font-size: 0.9rem; }
|
||||||
|
.scan-item-mac { font-size: 0.75rem; color: var(--muted); font-family: monospace; }
|
||||||
|
.btn-add-small {
|
||||||
|
padding: 0.4rem 0.9rem; background: var(--accent);
|
||||||
|
color: white; border: none; border-radius: 8px;
|
||||||
|
font-size: 0.8rem; font-weight: 600; cursor: pointer;
|
||||||
|
white-space: nowrap; flex-shrink: 0; transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.btn-add-small:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
background: none; border: 1px solid var(--border); color: var(--muted);
|
||||||
|
padding: 0.35rem 0.8rem; border-radius: 999px; font-size: 0.78rem;
|
||||||
|
cursor: pointer; transition: all 0.2s; margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.header h1 { font-size: 1.3rem; }
|
||||||
|
.card-grid { grid-template-columns: 1fr; }
|
||||||
|
body { padding: 1rem 0.75rem 4rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">⚡</div>
|
||||||
|
<h1>Wake-on-LAN</h1>
|
||||||
|
<p>Wake up your devices remotely via Magic Packet</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-chip">Total: <span class="total-count" id="totalCount">0</span></div>
|
||||||
|
<div class="stat-chip">Online: <span class="online-count" id="onlineCount">0</span></div>
|
||||||
|
<div class="stat-chip">Offline: <span class="offline-count" id="offlineCount">0</span></div>
|
||||||
|
<button class="refresh-btn" onclick="refreshStatus()">↻ Refresh Status</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-grid" id="deviceGrid"></div>
|
||||||
|
|
||||||
|
<!-- Scan Panel -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header" onclick="togglePanel(this)">
|
||||||
|
<span>🔍</span><h3>Scan Network</h3><span class="chevron">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<p class="scan-hint">Finds all active devices on your local network. Takes 10–30 seconds.</p>
|
||||||
|
<button class="btn-primary" id="scanBtn" onclick="scanNetwork()">🔍 Start Scan</button>
|
||||||
|
<div class="scan-status" id="scanStatus"></div>
|
||||||
|
<div id="scanResults"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Device Panel -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header" onclick="togglePanel(this)">
|
||||||
|
<span>➕</span><h3>Add Device</h3><span class="chevron">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<input type="text" id="newName" placeholder="PC Name (e.g. Gaming-PC)" />
|
||||||
|
<input type="text" id="newMac" placeholder="MAC Address (AA:BB:CC:DD:EE:FF)" />
|
||||||
|
<input type="text" id="newIp" placeholder="IP Address (optional, for online status)" />
|
||||||
|
<button class="btn-primary" onclick="addDevice()">💾 Save Device</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let devices = [];
|
||||||
|
let statusData = [];
|
||||||
|
|
||||||
|
function togglePanel(header) {
|
||||||
|
header.classList.toggle('open');
|
||||||
|
header.nextElementSibling.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevices() {
|
||||||
|
const res = await fetch('/devices');
|
||||||
|
devices = await res.json();
|
||||||
|
renderDevices();
|
||||||
|
refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevices() {
|
||||||
|
document.getElementById('totalCount').textContent = devices.length;
|
||||||
|
const grid = document.getElementById('deviceGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
if (devices.length === 0) {
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">🖥️</div>
|
||||||
|
<p>No devices yet.<br>Scan your network or add one manually.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
devices.forEach((d, i) => {
|
||||||
|
const s = statusData[i];
|
||||||
|
const isOnline = s === true;
|
||||||
|
const hasIp = !!d.ip;
|
||||||
|
const dotClass = hasIp ? (isOnline ? 'online' : 'offline') : '';
|
||||||
|
const labelText = hasIp ? (isOnline ? '● Online' : '● Offline') : '○ No IP set';
|
||||||
|
const labelClass = hasIp ? (isOnline ? 'online' : 'offline') : 'unknown';
|
||||||
|
|
||||||
|
grid.innerHTML += `
|
||||||
|
<div class="card ${isOnline ? 'is-online' : ''}" id="card-${i}">
|
||||||
|
<div class="card-top">
|
||||||
|
<div class="card-icon ${isOnline ? 'online' : ''}">🖥️</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<div class="status-dot ${dotClass}" title="${labelText}"></div>
|
||||||
|
<button class="btn-del" onclick="deleteDevice(${i})" title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-name">${d.name}</div>
|
||||||
|
<div class="card-mac">${d.mac}</div>
|
||||||
|
${d.ip ? `<div class="card-ip">IP: ${d.ip}</div>` : ''}
|
||||||
|
<div class="status-label ${labelClass}">${labelText}</div>
|
||||||
|
<button class="btn-wake" onclick="wakeDevice('${d.mac}', ${i})">⚡ Wake Up</button>
|
||||||
|
<div class="feedback-msg" id="status-${i}"></div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
if (devices.length === 0) return;
|
||||||
|
const res = await fetch('/status');
|
||||||
|
statusData = await res.json();
|
||||||
|
const online = statusData.filter(s => s === true).length;
|
||||||
|
const offline = statusData.filter(s => s === false).length;
|
||||||
|
document.getElementById('onlineCount').textContent = online;
|
||||||
|
document.getElementById('offlineCount').textContent = offline;
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wakeDevice(mac, idx) {
|
||||||
|
const el = document.getElementById(`status-${idx}`);
|
||||||
|
el.textContent = 'Sending Magic Packet...'; el.className = 'feedback-msg';
|
||||||
|
const res = await fetch(`/wake/${mac}`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
el.textContent = data.message;
|
||||||
|
el.className = `feedback-msg ${data.status === 'success' ? 'success' : 'error'}`;
|
||||||
|
setTimeout(() => { el.textContent = ''; el.className = 'feedback-msg'; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDevice(idx) {
|
||||||
|
if (!confirm('Remove this device?')) return;
|
||||||
|
await fetch(`/devices/${idx}`, { method: 'DELETE' });
|
||||||
|
await loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDevice() {
|
||||||
|
const name = document.getElementById('newName').value.trim();
|
||||||
|
const mac = document.getElementById('newMac').value.trim();
|
||||||
|
const ip = document.getElementById('newIp').value.trim();
|
||||||
|
if (!name || !mac) return alert('Please enter a name and MAC address!');
|
||||||
|
await fetch('/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, mac, ip })
|
||||||
|
});
|
||||||
|
document.getElementById('newName').value = '';
|
||||||
|
document.getElementById('newMac').value = '';
|
||||||
|
document.getElementById('newIp').value = '';
|
||||||
|
await loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanNetwork() {
|
||||||
|
const btn = document.getElementById('scanBtn');
|
||||||
|
const status = document.getElementById('scanStatus');
|
||||||
|
const results = document.getElementById('scanResults');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ Scanning...';
|
||||||
|
status.innerHTML = '<span style="color:var(--warn)">nmap running, please wait...</span>';
|
||||||
|
results.innerHTML = '';
|
||||||
|
const res = await fetch('/scan', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
btn.disabled = false; btn.textContent = '🔍 Start Scan';
|
||||||
|
if (data.status === 'error') {
|
||||||
|
status.innerHTML = `<span style="color:var(--danger)">Error: ${data.message}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.devices.length === 0) {
|
||||||
|
status.innerHTML = '<span style="color:var(--danger)">No devices with MAC found.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.innerHTML = `<span style="color:var(--success)">${data.devices.length} device(s) found</span>`;
|
||||||
|
results.innerHTML = data.devices.map(d => `
|
||||||
|
<div class="scan-item">
|
||||||
|
<div>
|
||||||
|
<div class="scan-item-name">${d.name}</div>
|
||||||
|
<div class="scan-item-mac">${d.mac} · ${d.ip}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-add-small" onclick="addFromScan('${d.name}','${d.mac}','${d.ip}')">+ Add</button>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFromScan(name, mac, ip) {
|
||||||
|
await fetch('/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, mac, ip })
|
||||||
|
});
|
||||||
|
await loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDevices();
|
||||||
|
setInterval(refreshStatus, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user