mirror of
https://github.com/jayofelony/pwnagotchi.git
synced 2026-07-28 14:37:00 -07:00
feat(ui): enhance web interface with new logtail and web configuration features
Signed-off-by: Jeroen Oudshoorn <oudshoorn.jeroen@gmail.com>
This commit is contained in:
Generated
+1
-1
@@ -6,7 +6,7 @@
|
||||
<paths name="pwnagotchi">
|
||||
<serverdata>
|
||||
<mappings>
|
||||
<mapping deploy="/home/pi/.pwn/lib/python3.13" local="$PROJECT_DIR$" web="/" />
|
||||
<mapping deploy="/opt/.pwn/lib/python3.13/site-packages" local="$PROJECT_DIR$" web="/" />
|
||||
</mappings>
|
||||
</serverdata>
|
||||
</paths>
|
||||
|
||||
@@ -222,6 +222,12 @@ origin = ""
|
||||
port = 8080
|
||||
on_frame = ""
|
||||
|
||||
[ui.web.theme]
|
||||
# Theme customization for the web interface. Use RGB values for the accent color.
|
||||
accent_r = 76
|
||||
accent_g = 175
|
||||
accent_b = 80
|
||||
|
||||
[ui.display]
|
||||
enabled = false
|
||||
rotation = 180
|
||||
@@ -262,4 +268,4 @@ mount = "/var/tmp/pwnagotchi"
|
||||
size = "10M"
|
||||
sync = 3600
|
||||
zram = true
|
||||
rsync = true
|
||||
rsync = true
|
||||
@@ -5,148 +5,390 @@ import os
|
||||
import subprocess
|
||||
import time
|
||||
import socket
|
||||
import threading
|
||||
import glob
|
||||
from flask import render_template_string
|
||||
|
||||
|
||||
class AutoBackup(plugins.Plugin):
|
||||
__author__ = 'WPA2'
|
||||
__version__ = '1.1.3'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = 'Backs up files when internet is available, with support for excludes.'
|
||||
__author__ = "WPA2"
|
||||
__version__ = "2.2"
|
||||
__license__ = "GPL3"
|
||||
__description__ = (
|
||||
"Backs up Pwnagotchi configuration and data, keeping recent backups."
|
||||
)
|
||||
|
||||
# Hardcoded defaults for Pwnagotchi
|
||||
DEFAULT_FILES = [
|
||||
"/root/settings.yaml",
|
||||
"/root/client_secrets.json",
|
||||
"/root/.api-report.json",
|
||||
"/root/.ssh",
|
||||
"/root/.bashrc",
|
||||
"/root/.profile",
|
||||
"/root/peers",
|
||||
"/etc/pwnagotchi/",
|
||||
"/usr/local/share/pwnagotchi/custom-plugins",
|
||||
"/etc/ssh/",
|
||||
"/home/pi/handshakes/",
|
||||
"/home/pi/.bashrc",
|
||||
"/home/pi/.profile",
|
||||
"/home/pi/.wpa_sec_uploads",
|
||||
]
|
||||
|
||||
DEFAULT_INTERVAL_SECONDS = 60 * 60 # 60 minutes
|
||||
DEFAULT_MAX_BACKUPS = 3
|
||||
DEFAULT_EXCLUDE = [
|
||||
"/etc/pwnagotchi/logs/*",
|
||||
"*.bak",
|
||||
"*.tmp",
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self.ready = False
|
||||
self.tries = 0
|
||||
# Used to throttle repeated log messages for "backup not due yet"
|
||||
self.last_not_due_logged = 0
|
||||
# Store the status file path separately.
|
||||
self.status_file = '/root/.auto-backup'
|
||||
self.status_file = "/root/.auto-backup"
|
||||
self.status = StatusFile(self.status_file)
|
||||
self.lock = threading.Lock()
|
||||
self.backup_in_progress = False
|
||||
self.hostname = socket.gethostname()
|
||||
self._agent = None
|
||||
|
||||
def on_loaded(self):
|
||||
required_options = ['files', 'interval', 'backup_location', 'max_tries']
|
||||
for opt in required_options:
|
||||
if opt not in self.options or self.options[opt] is None:
|
||||
logging.error(f"AUTO-BACKUP: Option '{opt}' is not set.")
|
||||
return
|
||||
"""Validate only required option: backup_location"""
|
||||
if (
|
||||
"backup_location" not in self.options
|
||||
or self.options["backup_location"] is None
|
||||
):
|
||||
logging.error("AUTO-BACKUP: Option 'backup_location' is not set.")
|
||||
return
|
||||
|
||||
# If no custom command(s) are provided, use the default plain tar command.
|
||||
# The command includes a placeholder for {excludes} so that if no excludes are set, it will be empty.
|
||||
if 'commands' not in self.options or not self.options['commands']:
|
||||
self.options['commands'] = ["tar cf {backup_file} {excludes} {files}"]
|
||||
self.ready = True
|
||||
logging.info("AUTO-BACKUP: Successfully loaded.")
|
||||
self.hostname = socket.gethostname()
|
||||
|
||||
def get_interval_seconds(self):
|
||||
"""
|
||||
Convert the interval option into seconds.
|
||||
Supports:
|
||||
- "daily" for 24 hours,
|
||||
- "hourly" for 60 minutes,
|
||||
- or a numeric value (interpreted as minutes).
|
||||
"""
|
||||
interval = self.options['interval']
|
||||
if isinstance(interval, str):
|
||||
if interval.lower() == "daily":
|
||||
return 24 * 60 * 60
|
||||
elif interval.lower() == "hourly":
|
||||
return 60 * 60
|
||||
else:
|
||||
try:
|
||||
minutes = float(interval)
|
||||
return minutes * 60
|
||||
except ValueError:
|
||||
logging.error("AUTO-BACKUP: Invalid interval format. Defaulting to daily interval.")
|
||||
return 24 * 60 * 60
|
||||
elif isinstance(interval, (int, float)):
|
||||
return float(interval) * 60
|
||||
# Read config with internal defaults - DO NOT modify self.options
|
||||
self.files = self.options.get("files", self.DEFAULT_FILES)
|
||||
self.interval_seconds = self.options.get(
|
||||
"interval_seconds", self.DEFAULT_INTERVAL_SECONDS
|
||||
)
|
||||
self.max_backups = self.options.get(
|
||||
"max_backups_to_keep", self.DEFAULT_MAX_BACKUPS
|
||||
)
|
||||
self.exclude = self.options.get("exclude", self.DEFAULT_EXCLUDE)
|
||||
self.include = self.options.get("include", [])
|
||||
|
||||
# Handle commands: if old format, use correct default internally
|
||||
commands = self.options.get("commands", ["tar", "czf"])
|
||||
if isinstance(commands, str) or (
|
||||
isinstance(commands, list)
|
||||
and len(commands) == 1
|
||||
and isinstance(commands[0], str)
|
||||
and "{" in str(commands)
|
||||
):
|
||||
logging.warning(
|
||||
"AUTO-BACKUP: Old command format detected in config, using default: tar czf"
|
||||
)
|
||||
self.commands = ["tar", "czf"]
|
||||
elif not commands:
|
||||
self.commands = ["tar", "czf"]
|
||||
else:
|
||||
logging.error("AUTO-BACKUP: Unrecognized type for interval. Defaulting to daily interval.")
|
||||
return 24 * 60 * 60
|
||||
self.commands = commands
|
||||
|
||||
# Validate include paths if specified
|
||||
if self.include:
|
||||
if not isinstance(self.include, list):
|
||||
self.include = [self.include]
|
||||
|
||||
for path in self.include:
|
||||
if not os.path.exists(path):
|
||||
logging.warning(
|
||||
f"AUTO-BACKUP: include path '{path}' does not exist, will skip if still missing at backup time"
|
||||
)
|
||||
|
||||
self.ready = True
|
||||
include_msg = (
|
||||
f", includes: {len(self.include)} additional path(s)"
|
||||
if self.include
|
||||
else ""
|
||||
)
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Plugin loaded for host '{self.hostname}'. Interval: 60min, Backups kept: {self.max_backups}{include_msg}"
|
||||
)
|
||||
|
||||
def is_backup_due(self):
|
||||
"""
|
||||
Determines if enough time has passed since the last backup.
|
||||
If the status file does not exist, a backup is due.
|
||||
"""
|
||||
interval_sec = self.get_interval_seconds()
|
||||
"""Check if backup is due based on interval."""
|
||||
try:
|
||||
last_backup = os.path.getmtime(self.status_file)
|
||||
except OSError:
|
||||
# Status file doesn't exist—backup is due.
|
||||
return True
|
||||
now = time.time()
|
||||
return (now - last_backup) >= interval_sec
|
||||
return (time.time() - last_backup) >= self.interval_seconds
|
||||
|
||||
def on_internet_available(self, agent):
|
||||
def _cleanup_old_backups(self):
|
||||
"""Deletes the oldest backups if we exceed the limit."""
|
||||
try:
|
||||
backup_dir = self.options["backup_location"]
|
||||
max_keep = self.max_backups
|
||||
|
||||
# Filter by this device's hostname
|
||||
search_pattern = os.path.join(
|
||||
backup_dir, f"{self.hostname}-backup-*.tar.gz"
|
||||
)
|
||||
files = glob.glob(search_pattern)
|
||||
|
||||
if not files:
|
||||
logging.debug("AUTO-BACKUP: No backup files found for cleanup")
|
||||
return
|
||||
|
||||
# Sort files by modification time (oldest first)
|
||||
files.sort(key=os.path.getmtime)
|
||||
|
||||
# Calculate how many to delete
|
||||
if len(files) > max_keep:
|
||||
num_to_delete = len(files) - max_keep
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Found {len(files)} backups, keeping {max_keep}, deleting {num_to_delete} old backup(s)..."
|
||||
)
|
||||
|
||||
for old_file in files[:num_to_delete]:
|
||||
try:
|
||||
os.remove(old_file)
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Deleted: {os.path.basename(old_file)}"
|
||||
)
|
||||
except OSError as e:
|
||||
logging.error(f"AUTO-BACKUP: Failed to delete {old_file}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"AUTO-BACKUP: Cleanup error: {e}")
|
||||
|
||||
def _run_backup_thread(self, agent, existing_files):
|
||||
"""Execute backup in separate thread."""
|
||||
try:
|
||||
backup_location = self.options["backup_location"]
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
if not os.path.exists(backup_location):
|
||||
try:
|
||||
os.makedirs(backup_location)
|
||||
logging.info(
|
||||
f"AUTO-BACKUP: Created backup directory: {backup_location}"
|
||||
)
|
||||
except OSError as e:
|
||||
logging.error(
|
||||
f"AUTO-BACKUP: Failed to create backup directory: {e}"
|
||||
)
|
||||
return
|
||||
|
||||
# Add timestamp to filename
|
||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_file = os.path.join(
|
||||
backup_location, f"{self.hostname}-backup-{timestamp}.tar.gz"
|
||||
)
|
||||
|
||||
# Try to update display if agent is available
|
||||
if agent:
|
||||
try:
|
||||
display = agent.view()
|
||||
display.set("status", "Backing up...")
|
||||
display.update()
|
||||
except:
|
||||
pass
|
||||
|
||||
logging.info(f"AUTO-BACKUP: Starting backup to {backup_file}...")
|
||||
|
||||
# Build command
|
||||
command_list = list(self.commands)
|
||||
command_list.append(backup_file)
|
||||
|
||||
# Add exclusions
|
||||
for pattern in self.exclude:
|
||||
command_list.append(f"--exclude={pattern}")
|
||||
|
||||
# Add files to backup
|
||||
command_list.extend(existing_files)
|
||||
|
||||
# Execute backup command
|
||||
process = subprocess.Popen(
|
||||
command_list,
|
||||
shell=False,
|
||||
stdin=None,
|
||||
stdout=open("/dev/null", "w"),
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
_, stderr_output = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise OSError(
|
||||
f"Backup command failed with code {process.returncode}: {stderr_output.decode('utf-8').strip()}"
|
||||
)
|
||||
|
||||
logging.info(f"AUTO-BACKUP: Backup successful: {backup_file}")
|
||||
|
||||
# Run cleanup after successful backup
|
||||
self._cleanup_old_backups()
|
||||
|
||||
# Try to update display if agent is available
|
||||
if agent:
|
||||
try:
|
||||
display = agent.view()
|
||||
display.set("status", "Backup done!")
|
||||
display.update()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Update status file timestamp
|
||||
self.status.update()
|
||||
|
||||
# Reset try counter on success
|
||||
self.tries = 0
|
||||
|
||||
except Exception as e:
|
||||
self.tries += 1
|
||||
logging.error(f"AUTO-BACKUP: Backup error (attempt {self.tries}): {e}")
|
||||
finally:
|
||||
self.backup_in_progress = False
|
||||
|
||||
def on_ready(self, agent):
|
||||
"""Called when Pwnagotchi is ready. Set up backup scheduler."""
|
||||
if not self.ready:
|
||||
return
|
||||
|
||||
if self.options['max_tries'] and self.tries >= self.options['max_tries']:
|
||||
logging.info("AUTO-BACKUP: Maximum tries reached, skipping backup.")
|
||||
self._agent = agent
|
||||
|
||||
# Start background scheduler thread
|
||||
scheduler_thread = threading.Thread(
|
||||
target=self._backup_scheduler_loop, daemon=True, name="AutoBackupScheduler"
|
||||
)
|
||||
scheduler_thread.start()
|
||||
|
||||
logging.info("AUTO-BACKUP: Periodic backup scheduler started")
|
||||
|
||||
def on_webhook(self, path, request):
|
||||
"""Handle web UI requests."""
|
||||
if request.method == "GET":
|
||||
if path == "/" or not path:
|
||||
action_path = (
|
||||
request.path
|
||||
if request.path.endswith("/backup")
|
||||
else "%s/backup" % request.path
|
||||
)
|
||||
ret = '<html><head><title>AUTO Backup</title><meta name="csrf_token" content="{{ csrf_token() }}"></head><body>'
|
||||
ret += "<h1>AUTO Backup</h1>"
|
||||
ret += "<p>Status: "
|
||||
if self.backup_in_progress:
|
||||
ret += "<b>Backup in progress...</b>"
|
||||
else:
|
||||
ret += "<b>Ready</b>"
|
||||
ret += "</p>"
|
||||
ret += '<form method="POST" action="%s">' % action_path
|
||||
ret += '<input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token() }}">'
|
||||
ret += '<input type="submit" value="Start Manual Backup" class="btn primary">'
|
||||
ret += "</form>"
|
||||
ret += "<hr>"
|
||||
ret += "<h2>Configuration</h2>"
|
||||
ret += '<table border="1" cellpadding="5">'
|
||||
ret += (
|
||||
"<tr><td><b>Backup Location:</b></td><td>"
|
||||
+ self.options.get("backup_location", "Not set")
|
||||
+ "</td></tr>"
|
||||
)
|
||||
ret += (
|
||||
"<tr><td><b>Interval:</b></td><td>"
|
||||
+ str(self.interval_seconds // 60)
|
||||
+ " minutes</b></td></tr>"
|
||||
)
|
||||
ret += (
|
||||
"<tr><td><b>Max Backups:</b></td><td>"
|
||||
+ str(self.max_backups)
|
||||
+ "</td></tr>"
|
||||
)
|
||||
ret += (
|
||||
"<tr><td><b>Include Paths:</b></td><td>"
|
||||
+ (", ".join(self.include) if self.include else "None")
|
||||
+ "</td></tr>"
|
||||
)
|
||||
ret += "</table>"
|
||||
ret += "</body></html>"
|
||||
return render_template_string(ret)
|
||||
|
||||
elif request.method == "POST":
|
||||
if path == "backup" or path == "/backup":
|
||||
result = self.manual_backup(self._agent)
|
||||
ret = '<html><head><title>AUTO Backup</title><meta name="csrf_token" content="{{ csrf_token() }}"></head><body>'
|
||||
ret += "<h1>AUTO Backup</h1>"
|
||||
ret += "<p><b>" + result["status"] + "</b></p>"
|
||||
ret += '<a href="/plugins/auto_backup/">Back</a>'
|
||||
ret += "</body></html>"
|
||||
return render_template_string(ret)
|
||||
|
||||
return "Not found"
|
||||
|
||||
def _backup_scheduler_loop(self):
|
||||
"""Background thread that checks if backup is due every minute."""
|
||||
while True:
|
||||
try:
|
||||
if self.ready:
|
||||
agent = getattr(self, "_agent", None)
|
||||
self._periodic_backup_check(agent)
|
||||
time.sleep(60)
|
||||
except Exception as e:
|
||||
logging.error(f"AUTO-BACKUP: Scheduler error: {e}")
|
||||
|
||||
def _get_backup_files(self):
|
||||
"""Collect all files to backup."""
|
||||
existing_files = list(filter(os.path.exists, self.files))
|
||||
if self.include:
|
||||
for path in self.include:
|
||||
if os.path.exists(path):
|
||||
existing_files.append(path)
|
||||
logging.debug(f"AUTO-BACKUP: Added include path: {path}")
|
||||
return existing_files
|
||||
|
||||
def _periodic_backup_check(self, agent=None):
|
||||
"""Periodic backup check."""
|
||||
if agent is None:
|
||||
agent = getattr(self, "_agent", None)
|
||||
|
||||
if not self.ready or self.backup_in_progress:
|
||||
return
|
||||
|
||||
if self.tries >= 3:
|
||||
return
|
||||
|
||||
if not self.is_backup_due():
|
||||
now = time.time()
|
||||
# Log "backup not due" only once every 600 seconds.
|
||||
if now - self.last_not_due_logged > 600:
|
||||
logging.info("AUTO-BACKUP: Backup not due yet based on the interval.")
|
||||
self.last_not_due_logged = now
|
||||
return
|
||||
|
||||
# Only include files/directories that exist to prevent errors.
|
||||
existing_files = list(filter(lambda f: os.path.exists(f), self.options['files']))
|
||||
existing_files = self._get_backup_files()
|
||||
if not existing_files:
|
||||
logging.warning("AUTO-BACKUP: No files found to backup.")
|
||||
logging.warning("AUTO-BACKUP: No files to backup exist")
|
||||
return
|
||||
files_to_backup = " ".join(existing_files)
|
||||
|
||||
# Build excludes string if configured.
|
||||
# Use get() so that if 'exclude' is missing or empty, we default to an empty list.
|
||||
excludes = ""
|
||||
exclude_list = self.options.get('exclude', [])
|
||||
if exclude_list:
|
||||
for pattern in exclude_list:
|
||||
excludes += f" --exclude='{pattern}'"
|
||||
self.backup_in_progress = True
|
||||
backup_thread = threading.Thread(
|
||||
target=self._run_backup_thread,
|
||||
args=(agent, existing_files),
|
||||
daemon=True,
|
||||
name="AutoBackupThread",
|
||||
)
|
||||
backup_thread.start()
|
||||
logging.debug("AUTO-BACKUP: Backup thread started")
|
||||
|
||||
# Get the backup location from config.
|
||||
backup_location = self.options['backup_location']
|
||||
def manual_backup(self, agent):
|
||||
"""Manually trigger a backup."""
|
||||
if self.backup_in_progress:
|
||||
return {"status": "Backup already in progress"}
|
||||
|
||||
# Retrieve the global config from agent. If agent.config is callable, call it.
|
||||
global_config = getattr(agent, 'config', None)
|
||||
if callable(global_config):
|
||||
global_config = global_config()
|
||||
if global_config is None:
|
||||
global_config = {}
|
||||
pwnagotchi_name = global_config.get('main', {}).get('name', socket.gethostname())
|
||||
backup_file = os.path.join(backup_location, f"{pwnagotchi_name}-backup.tar")
|
||||
existing_files = self._get_backup_files()
|
||||
if not existing_files:
|
||||
return {"status": "No files to backup"}
|
||||
|
||||
try:
|
||||
display = agent.view()
|
||||
logging.info("AUTO-BACKUP: Starting backup process...")
|
||||
display.set('status', 'Backing up ...')
|
||||
display.update()
|
||||
|
||||
# Execute each backup command.
|
||||
for cmd in self.options['commands']:
|
||||
formatted_cmd = cmd.format(backup_file=backup_file, files=files_to_backup, excludes=excludes)
|
||||
logging.info(f"AUTO-BACKUP: Running command: {formatted_cmd}")
|
||||
process = subprocess.Popen(
|
||||
formatted_cmd,
|
||||
shell=True,
|
||||
stdin=None,
|
||||
stdout=open("/dev/null", "w"),
|
||||
stderr=subprocess.STDOUT,
|
||||
executable="/bin/bash"
|
||||
)
|
||||
process.wait()
|
||||
if process.returncode > 0:
|
||||
raise OSError(f"Command failed with return code: {process.returncode}")
|
||||
|
||||
logging.info(f"AUTO-BACKUP: Backup completed successfully. File created at {backup_file}")
|
||||
display.set('status', 'Backup done!')
|
||||
display.update()
|
||||
self.status.update()
|
||||
except OSError as os_e:
|
||||
self.tries += 1
|
||||
logging.error(f"AUTO-BACKUP: Backup error: {os_e}")
|
||||
display.set('status', 'Backup failed!')
|
||||
display.update()
|
||||
self.backup_in_progress = True
|
||||
backup_thread = threading.Thread(
|
||||
target=self._run_backup_thread,
|
||||
args=(agent, existing_files),
|
||||
daemon=True,
|
||||
name="AutoBackupThread",
|
||||
)
|
||||
backup_thread.start()
|
||||
logging.info("AUTO-BACKUP: Manual backup triggered")
|
||||
return {"status": "Backup started - check logs for details"}
|
||||
|
||||
+437
-4968
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,13 @@
|
||||
import os
|
||||
import logging
|
||||
import threading
|
||||
from itertools import islice
|
||||
from time import sleep
|
||||
from datetime import datetime,timedelta
|
||||
from pwnagotchi import plugins
|
||||
from pwnagotchi.utils import StatusFile
|
||||
from flask import render_template_string
|
||||
from flask import jsonify
|
||||
from flask import abort
|
||||
from flask import Response
|
||||
|
||||
|
||||
TEMPLATE = """
|
||||
INDEX = """
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "plugins" %}
|
||||
{% block title %}
|
||||
@@ -20,83 +15,267 @@ TEMPLATE = """
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
{{ super() }}
|
||||
<style>
|
||||
/* Logtail-specific styles - plugin header */
|
||||
.logtail-header {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Search/Control Bar */
|
||||
#divTop {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#filter {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Autoscroll Toggle Wrapper */
|
||||
#divTop > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#autoscroll {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
label[for="autoscroll"] {
|
||||
display: inline;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-main);
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
font-family: var(--font-main);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Table Container */
|
||||
.table-container {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-body);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Time column */
|
||||
td:nth-child(1) {
|
||||
width: 130px;
|
||||
font-family: var(--font-pixel);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Level column */
|
||||
td:nth-child(2) {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
}
|
||||
|
||||
/* Message column */
|
||||
td:nth-child(3) {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Log Level Coloring */
|
||||
tr.default td:nth-child(2) {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
tr.info td:nth-child(2) {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
tr.warning td:nth-child(2) {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
tr.error td:nth-child(2) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
tr.debug td:nth-child(2) {
|
||||
color: #b39ddb;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media screen and (max-width: 768px) {
|
||||
#divTop {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#filter {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
padding: 12px 20px 12px 40px;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
width: 1px;
|
||||
white-space: nowrap;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
td:nth-child(1) {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
td:nth-child(2) {
|
||||
text-align: center;
|
||||
width: 65px;
|
||||
}
|
||||
thead, tr:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
#divTop {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
td:nth-child(1) {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
td:nth-child(2) {
|
||||
width: 55px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Mobile table display */
|
||||
table, tr, td {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border: none;
|
||||
}
|
||||
|
||||
tr:first-child, thead, th {
|
||||
display: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
div.sticky {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: table;
|
||||
float: left;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--card-bg);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
div.sticky > * {
|
||||
display: table-cell;
|
||||
}
|
||||
div.sticky > span {
|
||||
width: 1%;
|
||||
}
|
||||
div.sticky > input {
|
||||
|
||||
td {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.25rem;
|
||||
border: none;
|
||||
}
|
||||
tr.default {
|
||||
color: black;
|
||||
|
||||
td::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
tr.info {
|
||||
color: black;
|
||||
}
|
||||
tr.warning {
|
||||
color: darkorange;
|
||||
}
|
||||
tr.error {
|
||||
color: crimson;
|
||||
}
|
||||
tr.debug {
|
||||
color: blueviolet;
|
||||
}
|
||||
.ui-mobile .ui-page-active {
|
||||
overflow: visible;
|
||||
overflow-x: visible;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
var table = document.getElementById('table');
|
||||
var filter = document.getElementById('filter');
|
||||
var table = document.getElementById("table").querySelector("tbody");
|
||||
var filter = document.getElementById("filter");
|
||||
var filterVal = filter.value.toUpperCase();
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '{{ url_for('plugins') }}/logtail/stream');
|
||||
xhr.open("GET", "logtail/stream");
|
||||
xhr.send();
|
||||
var position = 0;
|
||||
var data;
|
||||
@@ -110,39 +289,39 @@ TEMPLATE = """
|
||||
filterVal = filter.value.toUpperCase();
|
||||
messages.slice(position, -1).forEach(function(value) {
|
||||
|
||||
if (value.charAt(0) != '[') {
|
||||
if (value.charAt(0) != "[") {
|
||||
msg = value;
|
||||
time = '';
|
||||
level = '';
|
||||
time = "";
|
||||
level = "";
|
||||
} else {
|
||||
data = value.split(']');
|
||||
time = data.shift() + ']';
|
||||
level = data.shift() + ']';
|
||||
msg = data.join(']');
|
||||
data = value.split("]");
|
||||
time = data.shift() + "]";
|
||||
level = data.shift() + "]";
|
||||
msg = data.join("]");
|
||||
|
||||
switch(level) {
|
||||
case ' [INFO]':
|
||||
colorClass = 'info';
|
||||
case " [INFO]":
|
||||
colorClass = "info";
|
||||
break;
|
||||
case ' [WARNING]':
|
||||
colorClass = 'warning';
|
||||
case " [WARNING]":
|
||||
colorClass = "warning";
|
||||
break;
|
||||
case ' [ERROR]':
|
||||
colorClass = 'error';
|
||||
case " [ERROR]":
|
||||
colorClass = "error";
|
||||
break;
|
||||
case ' [DEBUG]':
|
||||
colorClass = 'debug';
|
||||
case " [DEBUG]":
|
||||
colorClass = "debug";
|
||||
break;
|
||||
default:
|
||||
colorClass = 'default';
|
||||
colorClass = "default";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var tr = document.createElement('tr');
|
||||
var td1 = document.createElement('td');
|
||||
var td2 = document.createElement('td');
|
||||
var td3 = document.createElement('td');
|
||||
var tr = document.createElement("tr");
|
||||
var td1 = document.createElement("td");
|
||||
var td2 = document.createElement("td");
|
||||
var td3 = document.createElement("td");
|
||||
|
||||
td1.textContent = time;
|
||||
td2.textContent = level;
|
||||
@@ -169,7 +348,7 @@ TEMPLATE = """
|
||||
}
|
||||
|
||||
var timer;
|
||||
var scrollElm = document.getElementById('autoscroll');
|
||||
var scrollElm = document.getElementById("autoscroll");
|
||||
timer = setInterval(function() {
|
||||
handleNewData();
|
||||
if (scrollElm.checked) {
|
||||
@@ -181,7 +360,7 @@ TEMPLATE = """
|
||||
}, 1000);
|
||||
|
||||
var typingTimer;
|
||||
var doneTypingInterval = 1000;
|
||||
var doneTypingInterval = 500;
|
||||
|
||||
filter.onkeyup = function() {
|
||||
clearTimeout(typingTimer);
|
||||
@@ -193,50 +372,56 @@ TEMPLATE = """
|
||||
}
|
||||
|
||||
function doneTyping() {
|
||||
document.body.style.cursor = 'progress';
|
||||
var tr, tds, td, i, txtValue;
|
||||
filterVal = filter.value.toUpperCase();
|
||||
tr = table.getElementsByTagName("tr");
|
||||
for (i = 1; i < tr.length; i++) {
|
||||
for (i = 0; i < tr.length; i++) {
|
||||
txtValue = tr[i].textContent || tr[i].innerText;
|
||||
if (txtValue.toUpperCase().indexOf(filterVal) > -1) {
|
||||
if (filterVal.length === 0 || txtValue.toUpperCase().indexOf(filterVal) > -1) {
|
||||
tr[i].style.display = "table-row";
|
||||
} else {
|
||||
tr[i].style.display = "none";
|
||||
}
|
||||
}
|
||||
document.body.style.cursor = 'default';
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="sticky">
|
||||
<input type="text" id="filter" placeholder="Search for ..." title="Type in a filter">
|
||||
<span><input checked type="checkbox" id="autoscroll"></span>
|
||||
<span><label for="autoscroll"> Autoscroll to bottom</label><br></span>
|
||||
<div class="logtail-header">
|
||||
<h2>System Log</h2>
|
||||
<p>Real-time log viewer with filtering and auto-scroll capabilities</p>
|
||||
</div>
|
||||
|
||||
<div id="divTop">
|
||||
<input type="text" id="filter" placeholder="Filter logs..." title="Type to filter log messages">
|
||||
<span>
|
||||
<input type="checkbox" id="autoscroll" checked>
|
||||
<label for="autoscroll">Auto-scroll</label>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table id="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Level</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table id="table">
|
||||
<thead>
|
||||
<th>
|
||||
Time
|
||||
</th>
|
||||
<th>
|
||||
Level
|
||||
</th>
|
||||
<th>
|
||||
Message
|
||||
</th>
|
||||
</thead>
|
||||
</table>
|
||||
{% endblock %}
|
||||
"""
|
||||
|
||||
|
||||
class Logtail(plugins.Plugin):
|
||||
__author__ = '33197631+dadav@users.noreply.github.com'
|
||||
__version__ = '0.1.0'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = 'This plugin tails the logfile.'
|
||||
__author__ = "33197631+dadav@users.noreply.github.com"
|
||||
__version__ = "0.1.0"
|
||||
__license__ = "GPL3"
|
||||
__description__ = "This plugin tails the logfile."
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
@@ -258,15 +443,16 @@ class Logtail(plugins.Plugin):
|
||||
return "Plugin not ready"
|
||||
|
||||
if not path or path == "/":
|
||||
return render_template_string(TEMPLATE)
|
||||
return render_template_string(INDEX)
|
||||
|
||||
if path == "stream":
|
||||
|
||||
if path == 'stream':
|
||||
def generate():
|
||||
with open(self.config['main']['log']['path']) as f:
|
||||
yield ''.join(f.readlines()[-self.options.get('max-lines', 4096):])
|
||||
with open(self.config["main"]["log"]["path"]) as f:
|
||||
yield "".join(f.readlines()[-self.options.get("max-lines", 4096) :])
|
||||
while True:
|
||||
yield f.readline()
|
||||
|
||||
return Response(generate(), mimetype='text/plain')
|
||||
return Response(generate(), mimetype="text/plain")
|
||||
|
||||
abort(404)
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import logging
|
||||
import threading
|
||||
from time import sleep
|
||||
from datetime import datetime,timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from pwnagotchi import plugins
|
||||
from pwnagotchi.utils import StatusFile
|
||||
from flask import render_template_string
|
||||
@@ -12,252 +12,728 @@ TEMPLATE = """
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "plugins" %}
|
||||
{% block title %}
|
||||
Session stats
|
||||
Session Stats
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/css/jquery.jqplot.min.css"/>
|
||||
<link rel="stylesheet" href="/css/jquery.jqplot.css"/>
|
||||
<style>
|
||||
div.chart {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
/* Session Stats Header */
|
||||
.stats-header {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
div#session {
|
||||
|
||||
/* Session Selector */
|
||||
.session-selector {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
background-color: var(--card-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.session-selector label {
|
||||
display: inline;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
font-family: var(--font-pixel);
|
||||
}
|
||||
|
||||
#session {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Stats Container */
|
||||
.stats-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: var(--font-pixel);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.2rem;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-pixel);
|
||||
line-height: 1;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Charts Container */
|
||||
.charts-section {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.charts-section h3 {
|
||||
margin: 0 0 2rem 0;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 1.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
div.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.3s ease;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
div.chart:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 8px 25px rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
|
||||
}
|
||||
|
||||
div.chart canvas {
|
||||
max-height: 250px;
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.chart-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
font-family: var(--font-main);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.stats-container {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
div.chart {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.session-selector {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.session-selector label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#session {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
div.chart {
|
||||
height: 200px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script type="text/javascript" src="/js/jquery.jqplot.min.js"></script>
|
||||
<script type="text/javascript" src="/js/jquery.jqplot.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.mobile.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.json2.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.dateAxisRenderer.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.highlighter.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.cursor.js"></script>
|
||||
<script type="text/javascript" src="/js/plugins/jqplot.enhancedLegendRenderer.js"></script>
|
||||
<script src="/js/plugins/chart.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
$(document).ready(function(){
|
||||
var ajaxDataRenderer = function(url, plot, options) {
|
||||
var ret = null;
|
||||
$.ajax({
|
||||
async: false,
|
||||
url: url,
|
||||
dataType:"json",
|
||||
success: function(data) {
|
||||
ret = data;
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
};
|
||||
const charts = {};
|
||||
|
||||
function loadFiles(url, elm) {
|
||||
var data = ajaxDataRenderer(url);
|
||||
var x = document.getElementById(elm);
|
||||
$.each(data['files'], function( index, value ) {
|
||||
var option = document.createElement("option");
|
||||
option.text = value;
|
||||
x.add(option);
|
||||
});
|
||||
}
|
||||
|
||||
function loadData(url, elm, title, fill) {
|
||||
var data = ajaxDataRenderer(url);
|
||||
var plot_os = $.jqplot(elm, data.values,{
|
||||
title: title,
|
||||
stackSeries: fill,
|
||||
seriesDefaults: {
|
||||
showMarker: !fill,
|
||||
fill: fill,
|
||||
fillAndStroke: fill
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
renderer: $.jqplot.EnhancedLegendRenderer,
|
||||
placement: 'outsideGrid',
|
||||
labels: data.labels,
|
||||
location: 's',
|
||||
rendererOptions: {
|
||||
numberRows: '2',
|
||||
},
|
||||
rowSpacing: '0px'
|
||||
},
|
||||
axes:{
|
||||
xaxis:{
|
||||
renderer:$.jqplot.DateAxisRenderer,
|
||||
tickOptions:{formatString:'%H:%M:%S'}
|
||||
},
|
||||
yaxis:{
|
||||
tickOptions:{formatString:'%.2f'}
|
||||
}
|
||||
},
|
||||
highlighter: {
|
||||
show: true,
|
||||
sizeAdjust: 7.5
|
||||
},
|
||||
cursor:{
|
||||
show: true,
|
||||
tooltipLocation:'sw'
|
||||
async function fetchData(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch ${url}:`, error);
|
||||
return { values: [], labels: [] };
|
||||
}
|
||||
}).replot({
|
||||
axes:{
|
||||
xaxis:{
|
||||
renderer:$.jqplot.DateAxisRenderer,
|
||||
tickOptions:{formatString:'%H:%M:%S'}
|
||||
},
|
||||
yaxis:{
|
||||
tickOptions:{formatString:'%.2f'}
|
||||
}
|
||||
}
|
||||
|
||||
function getTransparentColor(color) {
|
||||
// Convert rgb() to rgba() with 0.2 opacity, or append hex opacity
|
||||
if (color.startsWith('rgb(')) {
|
||||
return color.replace('rgb(', 'rgba(').replace(')', ', 0.2)');
|
||||
}
|
||||
return color + '33'; // hex format
|
||||
}
|
||||
|
||||
function createChart(elementId, title, data) {
|
||||
const container = document.getElementById(elementId);
|
||||
if (!container || !data.values || data.values.length === 0) return;
|
||||
|
||||
if (charts[elementId]) charts[elementId].destroy();
|
||||
|
||||
const allLabels = new Set();
|
||||
data.values.forEach(values => {
|
||||
values.forEach(([ts]) => allLabels.add(ts));
|
||||
});
|
||||
}
|
||||
const labels = Array.from(allLabels).sort();
|
||||
|
||||
function loadSessionFiles() {
|
||||
loadFiles('/plugins/session-stats/session', 'session');
|
||||
$("#session").change(function() {
|
||||
loadSessionData();
|
||||
const datasets = data.values.map((values, index) => {
|
||||
const color = getChartColor(index);
|
||||
const valueMap = Object.fromEntries(values);
|
||||
const chartData = labels.map(ts => valueMap[ts] ?? null);
|
||||
|
||||
return {
|
||||
label: data.labels[index],
|
||||
data: chartData,
|
||||
borderColor: color,
|
||||
backgroundColor: getTransparentColor(color),
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.1,
|
||||
pointRadius: 1,
|
||||
pointHoverRadius: 4
|
||||
};
|
||||
});
|
||||
|
||||
let canvas = container.querySelector('canvas');
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
container.appendChild(canvas);
|
||||
}
|
||||
|
||||
// Calculate required width based on number of data points
|
||||
const dataPointCount = labels.length;
|
||||
const minPixelsPerPoint = 50; // minimum pixels for each data point
|
||||
const calculatedWidth = Math.max(container.clientWidth, dataPointCount * minPixelsPerPoint);
|
||||
|
||||
// Set canvas dimensions explicitly
|
||||
canvas.width = calculatedWidth;
|
||||
canvas.height = 250;
|
||||
|
||||
charts[elementId] = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: title,
|
||||
font: { size: 16, family: 'var(--font-pixel)', weight: 'bold' },
|
||||
color: '#fff',
|
||||
padding: 20
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#fff',
|
||||
font: { family: 'var(--font-main)', size: 12, weight: 'bold' },
|
||||
padding: 15,
|
||||
boxHeight: 4
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#000',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'var(--accent)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: '#333',
|
||||
display: true
|
||||
},
|
||||
ticks: {
|
||||
color: '#fff',
|
||||
font: { family: 'var(--font-main)', size: 11, weight: 'bold' },
|
||||
maxTicksLimit: 8
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: '#333',
|
||||
display: true
|
||||
},
|
||||
ticks: {
|
||||
color: '#fff',
|
||||
font: { family: 'var(--font-main)', size: 11, weight: 'bold' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add hint text if it doesn't exist
|
||||
if (!container.querySelector('.chart-hint')) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'chart-hint';
|
||||
hint.textContent = 'Scroll left/right to view more data';
|
||||
container.appendChild(hint);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSessionData() {
|
||||
var x = document.getElementById("session");
|
||||
var session = x.options[x.selectedIndex].text;
|
||||
loadData('/plugins/session-stats/os' + '?session=' + session, 'chart_os', 'OS', false)
|
||||
loadData('/plugins/session-stats/temp' + '?session=' + session, 'chart_temp', 'Temp', false)
|
||||
loadData('/plugins/session-stats/wifi' + '?session=' + session, 'chart_wifi', 'Wifi', true)
|
||||
loadData('/plugins/session-stats/duration' + '?session=' + session, 'chart_duration', 'Sleeping', true)
|
||||
loadData('/plugins/session-stats/reward' + '?session=' + session, 'chart_reward', 'Reward', false)
|
||||
loadData('/plugins/session-stats/epoch' + '?session=' + session, 'chart_epoch', 'Epochs', false)
|
||||
function getChartColor(index) {
|
||||
// Get accent color from CSS root variables
|
||||
const root = document.documentElement;
|
||||
const r = getComputedStyle(root).getPropertyValue('--accent-r').trim();
|
||||
const g = getComputedStyle(root).getPropertyValue('--accent-g').trim();
|
||||
const b = getComputedStyle(root).getPropertyValue('--accent-b').trim();
|
||||
const accentColor = `rgb(${r},${g},${b})`;
|
||||
// Use accent color as first chart color, then secondary colors
|
||||
const colors = [accentColor, '#ff9800', '#2196f3', '#f44336', '#9c27b0', '#00bcd4'];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
const sessionSelect = document.getElementById("session");
|
||||
const session = sessionSelect?.options[sessionSelect.selectedIndex]?.text || 'Current';
|
||||
const params = session === 'Current' ? '' : '?session=' + encodeURIComponent(session);
|
||||
|
||||
loadSessionFiles();
|
||||
loadSessionData();
|
||||
setInterval(loadSessionData, 60000);
|
||||
// Fetch summary stats
|
||||
const summary = await fetchData('/plugins/session-stats/summary' + params);
|
||||
if (summary.networks !== undefined) {
|
||||
document.getElementById('stat_networks').textContent = summary.networks;
|
||||
document.getElementById('stat_handshakes').textContent = summary.handshakes;
|
||||
document.getElementById('stat_deauths').textContent = summary.deauths;
|
||||
document.getElementById('stat_duration').textContent = summary.duration;
|
||||
document.getElementById('stat_temp').textContent = summary.temp || '0°C';
|
||||
document.getElementById('stat_mem').textContent = summary.mem || '0%';
|
||||
document.getElementById('stat_cpu').textContent = summary.cpu || '0%';
|
||||
}
|
||||
|
||||
// Fetch chart data
|
||||
const chartConfigs = [
|
||||
{ endpoint: 'networks', id: 'chart_networks', title: 'Networks Captured' },
|
||||
{ endpoint: 'handshakes', id: 'chart_handshakes', title: 'Handshakes Captured' },
|
||||
{ endpoint: 'deauths', id: 'chart_deauths', title: 'Deauthentications Sent' },
|
||||
{ endpoint: 'temp', id: 'chart_temp', title: 'Temperature (°C)' },
|
||||
{ endpoint: 'mem', id: 'chart_mem', title: 'Memory Usage (%)' },
|
||||
{ endpoint: 'cpu', id: 'chart_cpu', title: 'CPU Load (%)' }
|
||||
];
|
||||
|
||||
for (const config of chartConfigs) {
|
||||
const data = await fetchData('/plugins/session-stats/' + config.endpoint + params);
|
||||
createChart(config.id, config.title, data);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionFiles() {
|
||||
const data = await fetchData('/plugins/session-stats/sessions');
|
||||
const select = document.getElementById("session");
|
||||
data.files?.forEach(file => {
|
||||
const option = document.createElement("option");
|
||||
option.text = file;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener('change', updateStats);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSessionFiles();
|
||||
updateStats();
|
||||
setInterval(updateStats, 30000);
|
||||
});
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<select id="session">
|
||||
<option selected>Current</option>
|
||||
</select>
|
||||
<div id="chart_os" class="chart"></div>
|
||||
<div id="chart_temp" class="chart"></div>
|
||||
<div id="chart_wifi" class="chart"></div>
|
||||
<div id="chart_duration" class="chart"></div>
|
||||
<div id="chart_reward" class="chart"></div>
|
||||
<div id="chart_epoch" class="chart"></div>
|
||||
<div class="stats-header">
|
||||
<h2>Session Statistics</h2>
|
||||
<p>Real-time monitoring of WiFi capture metrics and system performance</p>
|
||||
</div>
|
||||
|
||||
<div class="session-selector">
|
||||
<label for="session">Session:</label>
|
||||
<select id="session">
|
||||
<option selected>Current</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="stats-container">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Networks Captured</div>
|
||||
<div class="stat-value" id="stat_networks">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Handshakes</div>
|
||||
<div class="stat-value" id="stat_handshakes">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Deauths Sent</div>
|
||||
<div class="stat-value" id="stat_deauths">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Session Duration</div>
|
||||
<div class="stat-value" id="stat_duration">0s</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Temperature</div>
|
||||
<div class="stat-value" id="stat_temp">0°C</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Memory Usage</div>
|
||||
<div class="stat-value" id="stat_mem">0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">CPU Load</div>
|
||||
<div class="stat-value" id="stat_cpu">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-section">
|
||||
<h3>Trend Charts</h3>
|
||||
<div class="charts-grid">
|
||||
<div id="chart_networks" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_handshakes" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_deauths" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_temp" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_mem" class="chart"><canvas></canvas></div>
|
||||
<div id="chart_cpu" class="chart"><canvas></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
"""
|
||||
|
||||
class GhettoClock:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self._track = datetime.now()
|
||||
self._counter_thread = threading.Thread(target=self.counter)
|
||||
self._counter_thread.daemon = True
|
||||
self._counter_thread.start()
|
||||
|
||||
def counter(self):
|
||||
while True:
|
||||
with self.lock:
|
||||
self._track += timedelta(seconds=1)
|
||||
sleep(1)
|
||||
|
||||
def now(self):
|
||||
with self.lock:
|
||||
return self._track
|
||||
|
||||
|
||||
class SessionStats(plugins.Plugin):
|
||||
__author__ = '33197631+dadav@users.noreply.github.com'
|
||||
__version__ = '0.1.0'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = 'This plugin displays stats of the current session.'
|
||||
__author__ = "33197631+dadav@users.noreply.github.com modified by wsvdmeer"
|
||||
__version__ = "0.2.0"
|
||||
__license__ = "GPL3"
|
||||
__description__ = (
|
||||
"Displays WiFi capture stats including networks, handshakes, and deauths."
|
||||
)
|
||||
DEFAULT_UPDATE_INTERVAL = 15 # RPi-friendly: 15 sec = 4 disk writes/min
|
||||
DEFAULT_SAVE_PATH = (
|
||||
"/home/pi/pwnagotchi/sessions/" # Standard location for user data
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.options = dict()
|
||||
self.stats = dict()
|
||||
self.clock = GhettoClock()
|
||||
self.initialized = False
|
||||
self.running = False
|
||||
self.agent = None
|
||||
self.realtime_thread = None
|
||||
|
||||
def on_loaded(self):
|
||||
"""
|
||||
Gets called when the plugin gets loaded
|
||||
"""
|
||||
# this has to happen in "loaded" because the options are not yet
|
||||
# available in the __init__
|
||||
os.makedirs(self.options['save_directory'], exist_ok=True)
|
||||
self.session_name = "stats_{}.json".format(self.clock.now().strftime("%Y_%m_%d_%H_%M"))
|
||||
self.session = StatusFile(os.path.join(self.options['save_directory'],
|
||||
self.session_name),
|
||||
data_format='json')
|
||||
logging.info("Session-stats plugin loaded.")
|
||||
# Use default save path if not configured
|
||||
save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH)
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
self.session_name = "stats_{}.json".format(
|
||||
datetime.now().strftime("%Y_%m_%d_%H_%M")
|
||||
)
|
||||
self.session = StatusFile(
|
||||
os.path.join(save_dir, self.session_name),
|
||||
data_format="json",
|
||||
)
|
||||
|
||||
logging.info(f"Session-stats plugin loaded. Saving to: {save_dir}")
|
||||
|
||||
# Try to load historical data from the most recent previous session
|
||||
try:
|
||||
session_files = sorted(
|
||||
[
|
||||
f
|
||||
for f in os.listdir(save_dir)
|
||||
if f.startswith("stats_") and f.endswith(".json")
|
||||
]
|
||||
)
|
||||
if len(session_files) > 1: # More than just the current session
|
||||
last_session_file = session_files[
|
||||
-2
|
||||
] # Second to last is the previous session
|
||||
last_session_path = os.path.join(save_dir, last_session_file)
|
||||
last_session = StatusFile(last_session_path, data_format="json")
|
||||
historical_data = last_session.data_field_or("data", default=dict())
|
||||
if historical_data:
|
||||
self.stats.update(historical_data)
|
||||
logging.info(
|
||||
f"Loaded {len(historical_data)} historical data points from {last_session_file}"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not load historical session data: {e}")
|
||||
|
||||
self.running = True
|
||||
self.realtime_thread = threading.Thread(
|
||||
target=self._realtime_loop, daemon=True, name="session-stats-realtime"
|
||||
)
|
||||
self.realtime_thread.start()
|
||||
logging.info("Session-stats realtime collection thread started.")
|
||||
|
||||
def on_ready(self, agent):
|
||||
"""Called when the agent is ready - store reference for realtime stats"""
|
||||
self.agent = agent
|
||||
logging.debug("Session-stats agent reference captured")
|
||||
|
||||
def on_ui_setup(self, ui):
|
||||
"""Get agent reference from UI when UI is set up"""
|
||||
if hasattr(ui, "_agent") and not self.agent:
|
||||
self.agent = ui._agent
|
||||
logging.debug("Session-stats agent reference captured from UI")
|
||||
|
||||
def on_unload(self):
|
||||
self.running = False
|
||||
if self.realtime_thread and self.realtime_thread.is_alive():
|
||||
self.realtime_thread.join(timeout=5)
|
||||
logging.info("Session-stats plugin unloaded.")
|
||||
|
||||
def _collect_stats(self):
|
||||
"""Collect current stats from agent (called both from realtime loop and epochs)"""
|
||||
if not self.agent:
|
||||
return None
|
||||
|
||||
try:
|
||||
networks = len(self.agent._access_points)
|
||||
handshakes = len(self.agent._handshakes)
|
||||
|
||||
stats_entry = {
|
||||
"num_peers": networks,
|
||||
"num_handshakes": handshakes,
|
||||
"num_deauths": 0, # Will be updated if on_epoch is called
|
||||
"temperature": 0, # Will be updated from system or epoch data
|
||||
"mem_usage": 0,
|
||||
"cpu_load": 0,
|
||||
}
|
||||
return stats_entry
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not collect stats: {e}")
|
||||
return None
|
||||
|
||||
def _realtime_loop(self):
|
||||
"""Background thread that collects stats periodically without waiting for epochs"""
|
||||
update_interval = self.options.get(
|
||||
"update_interval", self.DEFAULT_UPDATE_INTERVAL
|
||||
)
|
||||
agent_acquired = False
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
sleep(update_interval)
|
||||
|
||||
if not self.agent:
|
||||
if not agent_acquired:
|
||||
logging.debug(
|
||||
"Session-stats realtime loop: waiting for agent reference..."
|
||||
)
|
||||
continue
|
||||
|
||||
if not agent_acquired:
|
||||
logging.info(
|
||||
"Session-stats realtime loop: agent acquired, starting stats collection"
|
||||
)
|
||||
agent_acquired = True
|
||||
|
||||
with self.lock:
|
||||
stats_entry = self._collect_stats()
|
||||
if stats_entry:
|
||||
# Use high-resolution timestamp
|
||||
current_time = datetime.now()
|
||||
timestamp = current_time.strftime("%H:%M:%S.%f")[:-3]
|
||||
|
||||
# Only update if this is new data or initialized
|
||||
if not self.initialized:
|
||||
self.stats[timestamp] = stats_entry
|
||||
self.initialized = True
|
||||
self.session.update(data={"data": self.stats})
|
||||
logging.info(
|
||||
f"Session-stats initialized (realtime): {stats_entry['num_peers']} networks, "
|
||||
f"{stats_entry['num_handshakes']} handshakes"
|
||||
)
|
||||
else:
|
||||
# Add to stats if data changed
|
||||
last_stats = (
|
||||
list(self.stats.values())[-1] if self.stats else None
|
||||
)
|
||||
if last_stats and (
|
||||
stats_entry["num_peers"]
|
||||
!= last_stats.get("num_peers", 0)
|
||||
or stats_entry["num_handshakes"]
|
||||
!= last_stats.get("num_handshakes", 0)
|
||||
):
|
||||
self.stats[timestamp] = stats_entry
|
||||
self.session.update(data={"data": self.stats})
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Error in realtime stats loop: {e}")
|
||||
|
||||
def on_epoch(self, agent, epoch, epoch_data):
|
||||
"""
|
||||
Save the epoch_data to self.stats
|
||||
"""
|
||||
with self.lock:
|
||||
self.stats[self.clock.now().strftime("%H:%M:%S")] = epoch_data
|
||||
self.session.update(data={'data': self.stats})
|
||||
# Store agent reference if not already set
|
||||
if not self.agent:
|
||||
self.agent = agent
|
||||
logging.debug("Session-stats agent reference captured from epoch callback")
|
||||
else:
|
||||
self.agent = agent
|
||||
|
||||
@staticmethod
|
||||
def extract_key_values(data, subkeys):
|
||||
result = dict()
|
||||
result['values'] = list()
|
||||
result['labels'] = subkeys
|
||||
for plot_key in subkeys:
|
||||
v = [ [ts,d[plot_key]] for ts, d in data.items()]
|
||||
result['values'].append(v)
|
||||
return result
|
||||
with self.lock:
|
||||
# Collect epoch-specific system metrics
|
||||
stats_entry = {
|
||||
"num_peers": len(agent._access_points),
|
||||
"num_handshakes": len(agent._handshakes),
|
||||
"num_deauths": epoch_data.get("num_deauths", 0),
|
||||
"temperature": epoch_data.get("temperature", 0),
|
||||
"mem_usage": epoch_data.get("mem_usage", 0),
|
||||
"cpu_load": epoch_data.get("cpu_load", 0),
|
||||
}
|
||||
|
||||
# Add epoch data with high-resolution timestamp
|
||||
current_time = datetime.now()
|
||||
timestamp = current_time.strftime("%H:%M:%S.%f")[:-3]
|
||||
self.stats[timestamp] = stats_entry
|
||||
self.session.update(data={"data": self.stats})
|
||||
|
||||
if not self.initialized:
|
||||
self.initialized = True
|
||||
logging.info(
|
||||
f"Session-stats epoch update: {len(agent._access_points)} networks, "
|
||||
f"{len(agent._handshakes)} handshakes"
|
||||
)
|
||||
|
||||
def on_webhook(self, path, request):
|
||||
if not path or path == "/":
|
||||
return render_template_string(TEMPLATE)
|
||||
|
||||
session_param = request.args.get('session')
|
||||
|
||||
if path == "os":
|
||||
extract_keys = ['cpu_load','mem_usage',]
|
||||
elif path == "temp":
|
||||
extract_keys = ['temperature']
|
||||
elif path == "wifi":
|
||||
extract_keys = [
|
||||
'missed_interactions',
|
||||
'num_hops',
|
||||
'num_peers',
|
||||
'tot_bond',
|
||||
'avg_bond',
|
||||
'num_deauths',
|
||||
'num_associations',
|
||||
'num_handshakes',
|
||||
]
|
||||
elif path == "duration":
|
||||
extract_keys = [
|
||||
'duration_secs',
|
||||
'slept_for_secs',
|
||||
]
|
||||
elif path == "reward":
|
||||
extract_keys = [
|
||||
'reward',
|
||||
]
|
||||
elif path == "epoch":
|
||||
extract_keys = [
|
||||
'active_for_epochs',
|
||||
]
|
||||
elif path == "session":
|
||||
return jsonify({'files': os.listdir(self.options['save_directory'])})
|
||||
session_param = request.args.get("session")
|
||||
save_dir = self.options.get("save_directory", self.DEFAULT_SAVE_PATH)
|
||||
|
||||
with self.lock:
|
||||
data = self.stats
|
||||
if session_param and session_param != 'Current':
|
||||
file_stats = StatusFile(os.path.join(self.options['save_directory'], session_param), data_format='json')
|
||||
data = file_stats.data_field_or('data', default=dict())
|
||||
return jsonify(SessionStats.extract_key_values(data, extract_keys))
|
||||
if session_param and session_param != "Current":
|
||||
file_stats = StatusFile(
|
||||
os.path.join(save_dir, session_param),
|
||||
data_format="json",
|
||||
)
|
||||
data = file_stats.data_field_or("data", default=dict())
|
||||
|
||||
if path == "summary":
|
||||
total_networks = len(set(d.get("num_peers", 0) for d in data.values()))
|
||||
total_handshakes = sum(d.get("num_handshakes", 0) for d in data.values())
|
||||
total_deauths = sum(d.get("num_deauths", 0) for d in data.values())
|
||||
duration = len(data) if data else 0
|
||||
temp = max([d.get("temperature", 0) for d in data.values()], default=0)
|
||||
mem = max([d.get("mem_usage", 0) for d in data.values()], default=0)
|
||||
cpu = max([d.get("cpu_load", 0) for d in data.values()], default=0)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"networks": total_networks,
|
||||
"handshakes": total_handshakes,
|
||||
"deauths": total_deauths,
|
||||
"duration": f"{duration}s",
|
||||
"temp": f"{temp:.1f}°C",
|
||||
"mem": f"{mem:.1f}%",
|
||||
"cpu": f"{cpu:.1f}%",
|
||||
}
|
||||
)
|
||||
|
||||
elif path == "networks":
|
||||
return jsonify(self._extract_key_values(data, ["num_peers"]))
|
||||
elif path == "handshakes":
|
||||
return jsonify(self._extract_key_values(data, ["num_handshakes"]))
|
||||
elif path == "deauths":
|
||||
return jsonify(self._extract_key_values(data, ["num_deauths"]))
|
||||
elif path == "temp":
|
||||
return jsonify(self._extract_key_values(data, ["temperature"]))
|
||||
elif path == "mem":
|
||||
return jsonify(self._extract_key_values(data, ["mem_usage"]))
|
||||
elif path == "cpu":
|
||||
return jsonify(self._extract_key_values(data, ["cpu_load"]))
|
||||
elif path == "sessions":
|
||||
return jsonify({"files": os.listdir(save_dir)})
|
||||
|
||||
return jsonify({"error": "Unknown path"})
|
||||
|
||||
@staticmethod
|
||||
def _extract_key_values(data, subkeys):
|
||||
result = {"values": [], "labels": subkeys}
|
||||
for plot_key in subkeys:
|
||||
v = [[ts, d.get(plot_key, 0)] for ts, d in data.items()]
|
||||
result["values"].append(v)
|
||||
return result
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import json
|
||||
import toml
|
||||
import _thread
|
||||
import threading # FIX B5: replaced _thread with threading
|
||||
import pwnagotchi
|
||||
from pwnagotchi import restart, plugins
|
||||
from pwnagotchi.utils import save_config, merge_config
|
||||
@@ -18,174 +18,315 @@ INDEX = """
|
||||
{% block meta %}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=0" />
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{% endblock %}{% block styles %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
/* Webcfg-specific styles - plugin header */
|
||||
.webcfg-header {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Search/Control Bar */
|
||||
#divTop {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
top: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 5px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#searchText {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Select Box for Add Type */
|
||||
#selAddType {
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Wrapper spans */
|
||||
#divTop > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Table Container */
|
||||
.table-container {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
table, th, td {
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
thead {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
th {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: #eee;
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-body);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
table tr:nth-child(odd) {
|
||||
background-color: #fff;
|
||||
tbody tr:hover {
|
||||
background-color: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.05);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
table th {
|
||||
background-color: black;
|
||||
color: white;
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: 2px solid #f44336;
|
||||
padding: 4px 8px;
|
||||
/* Remove Button Column */
|
||||
td:nth-child(1) {
|
||||
width: 50px;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
margin: 4px 2px;
|
||||
-webkit-transition-duration: 0.4s; /* Safari */
|
||||
transition-duration: 0.4s;
|
||||
}
|
||||
|
||||
td:nth-child(1) .del_btn_wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Remove Button - Compact Icon Style */
|
||||
.remove {
|
||||
background-color: var(--danger);
|
||||
color: transparent;
|
||||
border: none;
|
||||
padding: 6px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-pixel);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 4px rgba(255, 85, 85, 0.2);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 6 5 6 21 6'%3E%3C/polyline%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'%3E%3C/path%3E%3Cline x1='10' y1='11' x2='10' y2='17'%3E%3C/line%3E%3Cline x1='14' y1='11' x2='14' y2='17'%3E%3C/line%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 18px;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
background-color: white;
|
||||
color: black;
|
||||
background-color: var(--danger-hover);
|
||||
box-shadow: 0 2px 6px rgba(255, 85, 85, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
#btnSave {
|
||||
.remove:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Save Button Group */
|
||||
#divSaveTop {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
background-color: #0061b0;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
z-index: 100;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
#divTop {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
#divTop > * {
|
||||
display: table-cell;
|
||||
}
|
||||
#divTop > span {
|
||||
width: 1%;
|
||||
}
|
||||
#divTop > input {
|
||||
width: 100%;
|
||||
#divSaveTop .btn {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
@media screen and (max-width:700px) {
|
||||
/* Responsive Design */
|
||||
@media screen and (max-width: 768px) {
|
||||
#divTop {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#searchText {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
td:nth-child(1) {
|
||||
width: 50px;
|
||||
padding: 10px 4px;
|
||||
}
|
||||
|
||||
.remove {
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 5px 5px;
|
||||
}
|
||||
|
||||
#divSaveTop {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
#divTop {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
td:nth-child(1) {
|
||||
width: 50px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.remove {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 4px 4px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#divSaveTop {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 70px;
|
||||
}
|
||||
|
||||
/* Mobile table display */
|
||||
table, tr, td {
|
||||
padding:0;
|
||||
border:1px solid black;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border:none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
tr:first-child, thead, th {
|
||||
display:none;
|
||||
border:none;
|
||||
display: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
tr {
|
||||
float: left;
|
||||
width: 100%;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
table tr:nth-child(odd) {
|
||||
background-color: #eee;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--card-bg);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
td {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding:1em;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.25rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
td::before {
|
||||
content:attr(data-label);
|
||||
word-wrap: break-word;
|
||||
background: #eee;
|
||||
border-right:2px solid black;
|
||||
width: 20%;
|
||||
float:left;
|
||||
padding:1em;
|
||||
font-weight: bold;
|
||||
margin:-1em 1em -1em -1em;
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.del_btn_wrapper {
|
||||
content:attr(data-label);
|
||||
word-wrap: break-word;
|
||||
background: #eee;
|
||||
border-right:2px solid black;
|
||||
width: 20%;
|
||||
float:left;
|
||||
padding:1em;
|
||||
font-weight: bold;
|
||||
margin:-1em 1em -1em -1em;
|
||||
td[data-label=""] {
|
||||
padding: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
td[data-label=""] .del_btn_wrapper {
|
||||
text-align: right;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="webcfg-header">
|
||||
<h2>Configuration Manager</h2>
|
||||
<p>Edit your Pwnagotchi configuration settings</p>
|
||||
</div>
|
||||
|
||||
<div id="divTop">
|
||||
<input type="text" id="searchText" placeholder="Search for options ..." title="Type an option name">
|
||||
<span><select id="selAddType"><option value="text">Text</option><option value="number">Number</option></select></span>
|
||||
<span><button id="btnAdd" type="button" onclick="addOption()">+</button></span>
|
||||
<span><button class="btn primary" type="button" onclick="addOption()">+</button></span>
|
||||
</div>
|
||||
|
||||
<div class="table-container" id="content"></div>
|
||||
|
||||
<div id="divSaveTop">
|
||||
<button id="btnSave" type="button" onclick="saveConfig()">Save and restart</button>
|
||||
<button id="btnSave" type="button" onclick="saveConfigNoRestart()">Merge and Save (CAUTION)</button>
|
||||
<button class="btn primary" type="button" onclick="saveConfig()">Save and restart</button>
|
||||
<button class="btn danger" type="button" onclick="saveConfigNoRestart()">Merge and Save (CAUTION)</button>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
@@ -204,7 +345,7 @@ INDEX = """
|
||||
td = document.createElement("td");
|
||||
td.setAttribute("data-label", "");
|
||||
btnDel = document.createElement("Button");
|
||||
btnDel.innerHTML = "X";
|
||||
btnDel.innerHTML = "";
|
||||
btnDel.onclick = function(){ delRow(this);};
|
||||
btnDel.className = "remove";
|
||||
divDelBtn.appendChild(btnDel);
|
||||
@@ -390,7 +531,7 @@ INDEX = """
|
||||
td = document.createElement("td");
|
||||
td.setAttribute("data-label", "");
|
||||
btnDel = document.createElement("Button");
|
||||
btnDel.innerHTML = "X";
|
||||
btnDel.innerHTML = "";
|
||||
btnDel.onclick = function(){ delRow(this);};
|
||||
btnDel.className = "remove";
|
||||
divDelBtn.appendChild(btnDel);
|
||||
@@ -427,7 +568,8 @@ INDEX = """
|
||||
input.type = 'text';
|
||||
input.value = '[]';
|
||||
}else{
|
||||
input.type = typeof(json[key]);
|
||||
var valType = typeof(json[key]);
|
||||
input.type = valType === 'string' ? 'text' : valType;
|
||||
input.value = json[key];
|
||||
}
|
||||
td.appendChild(input);
|
||||
@@ -487,14 +629,14 @@ def serializer(obj):
|
||||
|
||||
|
||||
class WebConfig(plugins.Plugin):
|
||||
__author__ = '33197631+dadav@users.noreply.github.com'
|
||||
__version__ = '1.0.0'
|
||||
__license__ = 'GPL3'
|
||||
__description__ = 'This plugin allows the user to make runtime changes.'
|
||||
__author__ = "33197631+dadav@users.noreply.github.com modified by wsvdmeer"
|
||||
__version__ = "1.0.0"
|
||||
__license__ = "GPL3"
|
||||
__description__ = "This plugin allows the user to make runtime changes."
|
||||
|
||||
def __init__(self):
|
||||
self.ready = False
|
||||
self.mode = 'MANU'
|
||||
self.mode = "MANU"
|
||||
self._agent = None
|
||||
|
||||
def on_config_changed(self, config):
|
||||
@@ -503,11 +645,11 @@ class WebConfig(plugins.Plugin):
|
||||
|
||||
def on_ready(self, agent):
|
||||
self._agent = agent
|
||||
self.mode = 'MANU' if agent.mode == 'manual' else 'AUTO'
|
||||
self.mode = "MANU" if agent.mode == "manual" else "AUTO"
|
||||
|
||||
def on_internet_available(self, agent):
|
||||
self._agent = agent
|
||||
self.mode = 'MANU' if agent.mode == 'manual' else 'AUTO'
|
||||
self.mode = "MANU" if agent.mode == "manual" else "AUTO"
|
||||
|
||||
def on_loaded(self):
|
||||
"""
|
||||
@@ -534,7 +676,7 @@ class WebConfig(plugins.Plugin):
|
||||
if path == "save-config":
|
||||
try:
|
||||
save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test
|
||||
_thread.start_new_thread(restart, (self.mode,))
|
||||
threading.Thread(target=restart, args=(self.mode,), daemon=True).start() # FIX B5
|
||||
return "success"
|
||||
except Exception as ex:
|
||||
logging.error(ex)
|
||||
@@ -542,13 +684,21 @@ class WebConfig(plugins.Plugin):
|
||||
elif path == "merge-save-config":
|
||||
try:
|
||||
self.config = merge_config(request.get_json(), self.config)
|
||||
pwnagotchi.config = merge_config(request.get_json(), pwnagotchi.config)
|
||||
pwnagotchi.config = merge_config(
|
||||
request.get_json(), pwnagotchi.config
|
||||
)
|
||||
logging.debug("PWNAGOTCHI CONFIG:\n%s" % repr(pwnagotchi.config))
|
||||
if self._agent:
|
||||
self._agent._config = merge_config(request.get_json(), self._agent._config)
|
||||
logging.debug(" Agent CONFIG:\n%s" % repr(self._agent._config))
|
||||
self._agent._config = merge_config(
|
||||
request.get_json(), self._agent._config
|
||||
)
|
||||
logging.debug(
|
||||
" Agent CONFIG:\n%s" % repr(self._agent._config)
|
||||
)
|
||||
logging.debug(" Updated CONFIG:\n%s" % request.get_json())
|
||||
save_config(request.get_json(), '/etc/pwnagotchi/config.toml') # test
|
||||
save_config(
|
||||
request.get_json(), "/etc/pwnagotchi/config.toml"
|
||||
) # test
|
||||
return "success"
|
||||
except Exception as ex:
|
||||
logging.error("[webcfg mergesave] %s" % ex)
|
||||
|
||||
+160
-80
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import base64
|
||||
import _thread
|
||||
import threading # FIX B5: replaced _thread with threading
|
||||
import secrets
|
||||
import json
|
||||
from functools import wraps
|
||||
@@ -9,8 +9,8 @@ from functools import wraps
|
||||
import flask
|
||||
|
||||
# https://stackoverflow.com/questions/14888799/disable-console-messages-in-flask-server
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
os.environ['WERKZEUG_RUN_MAIN'] = 'false'
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
os.environ["WERKZEUG_RUN_MAIN"] = "false"
|
||||
|
||||
import pwnagotchi
|
||||
import pwnagotchi.grid as grid
|
||||
@@ -32,21 +32,45 @@ class Handler:
|
||||
self._agent = agent
|
||||
self._app = app
|
||||
|
||||
self._app.add_url_rule('/', 'index', self.with_auth(self.index))
|
||||
self._app.add_url_rule('/ui', 'ui', self.with_auth(self.ui))
|
||||
# Dynamic theme CSS route
|
||||
self._app.add_url_rule("/css/theme.css", "dynamic_theme", self.dynamic_theme)
|
||||
|
||||
self._app.add_url_rule('/shutdown', 'shutdown', self.with_auth(self.shutdown), methods=['POST'])
|
||||
self._app.add_url_rule('/reboot', 'reboot', self.with_auth(self.reboot), methods=['POST'])
|
||||
self._app.add_url_rule('/restart', 'restart', self.with_auth(self.restart), methods=['POST'])
|
||||
self._app.add_url_rule("/", "index", self.with_auth(self.index))
|
||||
self._app.add_url_rule("/ui", "ui", self.with_auth(self.ui))
|
||||
|
||||
self._app.add_url_rule(
|
||||
"/shutdown", "shutdown", self.with_auth(self.shutdown), methods=["POST"]
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/reboot", "reboot", self.with_auth(self.reboot), methods=["POST"]
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/restart", "restart", self.with_auth(self.restart), methods=["POST"]
|
||||
)
|
||||
|
||||
# inbox
|
||||
self._app.add_url_rule('/inbox', 'inbox', self.with_auth(self.inbox))
|
||||
self._app.add_url_rule('/inbox/profile', 'inbox_profile', self.with_auth(self.inbox_profile))
|
||||
self._app.add_url_rule('/inbox/peers', 'inbox_peers', self.with_auth(self.inbox_peers))
|
||||
self._app.add_url_rule('/inbox/<id>', 'show_message', self.with_auth(self.show_message))
|
||||
self._app.add_url_rule('/inbox/<id>/<mark>', 'mark_message', self.with_auth(self.mark_message))
|
||||
self._app.add_url_rule('/inbox/new', 'new_message', self.with_auth(self.new_message))
|
||||
self._app.add_url_rule('/inbox/send', 'send_message', self.with_auth(self.send_message), methods=['POST'])
|
||||
self._app.add_url_rule("/inbox", "inbox", self.with_auth(self.inbox))
|
||||
self._app.add_url_rule(
|
||||
"/inbox/profile", "inbox_profile", self.with_auth(self.inbox_profile)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/peers", "inbox_peers", self.with_auth(self.inbox_peers)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/<id>", "show_message", self.with_auth(self.show_message)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/<id>/<mark>", "mark_message", self.with_auth(self.mark_message)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/new", "new_message", self.with_auth(self.new_message)
|
||||
)
|
||||
self._app.add_url_rule(
|
||||
"/inbox/send",
|
||||
"send_message",
|
||||
self.with_auth(self.send_message),
|
||||
methods=["POST"],
|
||||
)
|
||||
|
||||
# plugins
|
||||
plugins_with_auth = self.with_auth(self.plugins)
|
||||
@@ -58,52 +82,57 @@ class Handler:
|
||||
|
||||
def _check_creds(self, u, p):
|
||||
# trying to be timing attack safe
|
||||
return secrets.compare_digest(u, self._config['username']) and \
|
||||
secrets.compare_digest(p, self._config['password'])
|
||||
return secrets.compare_digest(
|
||||
u, self._config["username"]
|
||||
) and secrets.compare_digest(p, self._config["password"])
|
||||
|
||||
def with_auth(self, f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not self._config['auth']:
|
||||
if not self._config["auth"]:
|
||||
return f(*args, **kwargs)
|
||||
else:
|
||||
auth = request.authorization
|
||||
if not auth or not auth.username or not auth.password or not self._check_creds(auth.username,
|
||||
auth.password):
|
||||
return Response('Unauthorized', 401, {'WWW-Authenticate': 'Basic realm="Unauthorized"'})
|
||||
if (
|
||||
not auth
|
||||
or not auth.username
|
||||
or not auth.password
|
||||
or not self._check_creds(auth.username, auth.password)
|
||||
):
|
||||
return Response(
|
||||
"Unauthorized",
|
||||
401,
|
||||
{"WWW-Authenticate": 'Basic realm="Unauthorized"'},
|
||||
)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
def index(self):
|
||||
return render_template('index.html',
|
||||
title=pwnagotchi.name(),
|
||||
other_mode='AUTO' if self._agent.mode == 'manual' else 'MANU',
|
||||
fingerprint=self._agent.fingerprint())
|
||||
return render_template(
|
||||
"index.html",
|
||||
title=pwnagotchi.name(),
|
||||
other_mode="AUTO" if self._agent.mode == "manual" else "MANU",
|
||||
fingerprint=self._agent.fingerprint(),
|
||||
)
|
||||
|
||||
def inbox(self):
|
||||
page = request.args.get("p", default=1, type=int)
|
||||
inbox = {
|
||||
"pages": 1,
|
||||
"records": 0,
|
||||
"messages": []
|
||||
}
|
||||
inbox = {"pages": 1, "records": 0, "messages": []}
|
||||
error = None
|
||||
|
||||
try:
|
||||
if not grid.is_connected():
|
||||
raise Exception('not connected')
|
||||
raise Exception("not connected")
|
||||
|
||||
inbox = grid.inbox(page, with_pager=True)
|
||||
except Exception as e:
|
||||
logging.exception('error while reading pwnmail inbox')
|
||||
logging.exception("error while reading pwnmail inbox")
|
||||
error = str(e)
|
||||
|
||||
return render_template('inbox.html',
|
||||
name=pwnagotchi.name(),
|
||||
page=page,
|
||||
error=error,
|
||||
inbox=inbox)
|
||||
return render_template(
|
||||
"inbox.html", name=pwnagotchi.name(), page=page, error=error, inbox=inbox
|
||||
)
|
||||
|
||||
def inbox_profile(self):
|
||||
data = {}
|
||||
@@ -112,14 +141,16 @@ class Handler:
|
||||
try:
|
||||
data = grid.get_advertisement_data()
|
||||
except Exception as e:
|
||||
logging.exception('error while reading pwngrid data')
|
||||
logging.exception("error while reading pwngrid data")
|
||||
error = str(e)
|
||||
|
||||
return render_template('profile.html',
|
||||
name=pwnagotchi.name(),
|
||||
fingerprint=self._agent.fingerprint(),
|
||||
data=json.dumps(data, indent=2),
|
||||
error=error)
|
||||
return render_template(
|
||||
"profile.html",
|
||||
name=pwnagotchi.name(),
|
||||
fingerprint=self._agent.fingerprint(),
|
||||
data=json.dumps(data, indent=2),
|
||||
error=error,
|
||||
)
|
||||
|
||||
def inbox_peers(self):
|
||||
peers = {}
|
||||
@@ -128,13 +159,12 @@ class Handler:
|
||||
try:
|
||||
peers = grid.memory()
|
||||
except Exception as e:
|
||||
logging.exception('error while reading pwngrid peers')
|
||||
logging.exception("error while reading pwngrid peers")
|
||||
error = str(e)
|
||||
|
||||
return render_template('peers.html',
|
||||
name=pwnagotchi.name(),
|
||||
peers=peers,
|
||||
error=error)
|
||||
return render_template(
|
||||
"peers.html", name=pwnagotchi.name(), peers=peers, error=error
|
||||
)
|
||||
|
||||
def show_message(self, id):
|
||||
message = {}
|
||||
@@ -142,23 +172,22 @@ class Handler:
|
||||
|
||||
try:
|
||||
if not grid.is_connected():
|
||||
raise Exception('not connected')
|
||||
raise Exception("not connected")
|
||||
|
||||
message = grid.inbox_message(id)
|
||||
if message['data']:
|
||||
message['data'] = base64.b64decode(message['data']).decode("utf-8")
|
||||
if message["data"]:
|
||||
message["data"] = base64.b64decode(message["data"]).decode("utf-8")
|
||||
except Exception as e:
|
||||
logging.exception('error while reading pwnmail message %d' % int(id))
|
||||
logging.exception("error while reading pwnmail message %d" % int(id))
|
||||
error = str(e)
|
||||
|
||||
return render_template('message.html',
|
||||
name=pwnagotchi.name(),
|
||||
error=error,
|
||||
message=message)
|
||||
return render_template(
|
||||
"message.html", name=pwnagotchi.name(), error=error, message=message
|
||||
)
|
||||
|
||||
def new_message(self):
|
||||
to = request.args.get("to", default="")
|
||||
return render_template('new_message.html', to=to)
|
||||
return render_template("new_message.html", to=to)
|
||||
|
||||
def send_message(self):
|
||||
to = request.form["to"]
|
||||
@@ -167,7 +196,7 @@ class Handler:
|
||||
|
||||
try:
|
||||
if not grid.is_connected():
|
||||
raise Exception('not connected')
|
||||
raise Exception("not connected")
|
||||
|
||||
grid.send_message(to, message)
|
||||
except Exception as e:
|
||||
@@ -185,18 +214,41 @@ class Handler:
|
||||
|
||||
def plugins(self, name, subpath):
|
||||
if name is None:
|
||||
return render_template('plugins.html', loaded=plugins.loaded, database=plugins.database)
|
||||
# Determine which plugins are from the default folder
|
||||
default_plugins = set()
|
||||
default_path = os.path.join(
|
||||
os.path.dirname(os.path.realpath(plugins.__file__)), "default"
|
||||
)
|
||||
for plugin_name, plugin_path in plugins.database.items():
|
||||
if plugin_path.startswith(default_path):
|
||||
default_plugins.add(plugin_name)
|
||||
return render_template(
|
||||
"plugins.html",
|
||||
loaded=plugins.loaded,
|
||||
database=plugins.database,
|
||||
default_plugins=default_plugins,
|
||||
)
|
||||
|
||||
if name == 'toggle' and request.method == 'POST':
|
||||
checked = True if 'enabled' in request.form else False
|
||||
return 'success' if plugins.toggle_plugin(request.form['plugin'], checked) else 'failed'
|
||||
if name == "toggle" and request.method == "POST":
|
||||
checked = True if "enabled" in request.form else False
|
||||
return (
|
||||
"success"
|
||||
if plugins.toggle_plugin(request.form["plugin"], checked)
|
||||
else "failed"
|
||||
)
|
||||
|
||||
if name == 'upgrade' and request.method == 'POST':
|
||||
if name == "upgrade" and request.method == "POST":
|
||||
logging.info(f"Upgrading plugin: {request.form['plugin']}")
|
||||
os.system(f"pwnagotchi plugins update && pwnagotchi plugins upgrade {request.form['plugin']}")
|
||||
os.system(
|
||||
f"pwnagotchi plugins update && pwnagotchi plugins upgrade {request.form['plugin']}"
|
||||
)
|
||||
return redirect("/plugins")
|
||||
|
||||
if name in plugins.loaded and plugins.loaded[name] is not None and hasattr(plugins.loaded[name], 'on_webhook'):
|
||||
if (
|
||||
name in plugins.loaded
|
||||
and plugins.loaded[name] is not None
|
||||
and hasattr(plugins.loaded[name], "on_webhook")
|
||||
):
|
||||
try:
|
||||
return plugins.loaded[name].on_webhook(subpath, request)
|
||||
except Exception:
|
||||
@@ -207,32 +259,60 @@ class Handler:
|
||||
# serve a message and shuts down the unit
|
||||
def shutdown(self):
|
||||
try:
|
||||
return render_template('status.html', title=pwnagotchi.name(), go_back_after=60,
|
||||
message='Shutting down ...')
|
||||
return render_template(
|
||||
"status.html",
|
||||
title=pwnagotchi.name(),
|
||||
go_back_after=60,
|
||||
message="Shutting down ...",
|
||||
)
|
||||
finally:
|
||||
_thread.start_new_thread(pwnagotchi.shutdown, ())
|
||||
# FIX B5: replaced _thread.start_new_thread with threading.Thread
|
||||
threading.Thread(target=pwnagotchi.shutdown, daemon=True).start()
|
||||
|
||||
# serve a message and reboot the unit
|
||||
def reboot(self):
|
||||
try:
|
||||
return render_template('status.html', title=pwnagotchi.name(), go_back_after=60,
|
||||
message='Rebooting ...')
|
||||
finally:
|
||||
_thread.start_new_thread(pwnagotchi.reboot, ())
|
||||
try:
|
||||
return render_template(
|
||||
"status.html",
|
||||
title=pwnagotchi.name(),
|
||||
go_back_after=60,
|
||||
message="Rebooting ...",
|
||||
)
|
||||
finally:
|
||||
# FIX B5: replaced _thread.start_new_thread with threading.Thread
|
||||
threading.Thread(target=pwnagotchi.reboot, daemon=True).start()
|
||||
|
||||
# serve a message and restart the unit in the other mode
|
||||
def restart(self):
|
||||
mode = request.form['mode']
|
||||
if mode not in ('AUTO', 'MANU'):
|
||||
mode = 'MANU'
|
||||
mode = request.form["mode"]
|
||||
if mode not in ("AUTO", "MANU"):
|
||||
mode = "MANU"
|
||||
|
||||
try:
|
||||
return render_template('status.html', title=pwnagotchi.name(), go_back_after=30,
|
||||
message='Restarting in %s mode ...' % mode)
|
||||
return render_template(
|
||||
"status.html",
|
||||
title=pwnagotchi.name(),
|
||||
go_back_after=30,
|
||||
message="Restarting in %s mode ..." % mode,
|
||||
)
|
||||
finally:
|
||||
_thread.start_new_thread(pwnagotchi.restart, (mode,))
|
||||
# FIX B5: replaced _thread.start_new_thread with threading.Thread
|
||||
threading.Thread(
|
||||
target=pwnagotchi.restart, args=(mode,), daemon=True
|
||||
).start()
|
||||
|
||||
# serve dynamic CSS with accent color from config
|
||||
def dynamic_theme(self):
|
||||
"""Generate CSS accent RGB variables from config [ui.web.theme] section"""
|
||||
# Get RGB values from already-loaded config, fallback to default green
|
||||
r = self._config.get("theme", {}).get("accent_r", 76)
|
||||
g = self._config.get("theme", {}).get("accent_g", 175)
|
||||
b = self._config.get("theme", {}).get("accent_b", 80)
|
||||
|
||||
css = f":root {{\n --accent: rgb({r}, {g}, {b});\n --accent-r: {r};\n --accent-g: {g};\n --accent-b: {b};\n}}"
|
||||
return Response(css, mimetype="text/css")
|
||||
|
||||
# serve the PNG file with the display image
|
||||
def ui(self):
|
||||
with web.frame_lock:
|
||||
return send_file(web.frame_path, mimetype='image/png')
|
||||
return send_file(web.frame_path, mimetype="image/png")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,86 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
{% block head %}
|
||||
<head>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
{% block head %}
|
||||
<head>
|
||||
{% block meta %}
|
||||
<meta charset="utf-8">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#007bff" />
|
||||
<meta name="description" content="Pwnagotchi Web Interface" />
|
||||
{% endblock %}
|
||||
|
||||
<title>
|
||||
{% block title %}
|
||||
{% endblock %}
|
||||
</title>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" href="/js/jquery.mobile/jquery.mobile-1.4.5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="/css/style.css"/>
|
||||
<link rel="apple-touch-icon" href="/images/pwnagotchi.png">
|
||||
<link rel="icon" type="image/png" href="/images/pwnagotchi.png">
|
||||
{% endblock %}
|
||||
|
||||
</head>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<body>
|
||||
<div data-role="page">
|
||||
|
||||
{% if error %}
|
||||
<div id="error" class="error ui-content" data-role="popup" data-overlay-theme="a" data-theme="b">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<script>
|
||||
$(function(){
|
||||
$("#error").popup("open");
|
||||
});
|
||||
</script>
|
||||
<link rel="stylesheet" type="text/css" href="/css/style.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/css/theme.css" />
|
||||
{% if active_page == 'profile' %}
|
||||
<link rel="stylesheet" type="text/css" href="/css/profile.css" />
|
||||
{% elif active_page in ['inbox', 'new'] %}
|
||||
<link rel="stylesheet" type="text/css" href="/css/inbox.css" />
|
||||
{% elif active_page == 'plugins' %}
|
||||
<link rel="stylesheet" type="text/css" href="/css/plugins.css" />
|
||||
{% endif %}
|
||||
<link rel="apple-touch-icon" href="/images/pwnagotchi.png" />
|
||||
<link rel="icon" type="image/png" href="/images/pwnagotchi.png" />
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
{% set navigation = [
|
||||
( '/', 'home', 'eye', 'Home' ),
|
||||
( '/inbox', 'inbox', 'bars', 'Inbox' ),
|
||||
( '/inbox/new', 'new', 'mail', 'New' ),
|
||||
( '/inbox/profile', 'profile', 'info', 'Profile' ),
|
||||
( '/inbox/peers', 'peers', 'user', 'Peers' ),
|
||||
( '/plugins', 'plugins', 'grid', 'Plugins' ),
|
||||
] %}
|
||||
{% set active_page = active_page|default('inbox') %}
|
||||
{% endblock %} {% block body %}
|
||||
<body>
|
||||
<div class="page-container">
|
||||
{% if error %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
showToast("{{ error }}", 5000, "error");
|
||||
});
|
||||
</script>
|
||||
{% endif %} {% set navigation = [ ( '/', 'home', 'Home' ), ( '/inbox',
|
||||
'inbox', 'Inbox' ), ( '/inbox/new', 'new', 'New' ), ( '/inbox/profile',
|
||||
'profile', 'Profile' ), ( '/inbox/peers', 'peers', 'Peers' ), (
|
||||
'/plugins', 'plugins', 'Plugins' ), ] %} {% set active_page =
|
||||
active_page|default('inbox') %}
|
||||
|
||||
<div data-role="footer">
|
||||
<div data-role="navbar" data-iconpos="left">
|
||||
<ul>
|
||||
{% for href, id, icon, caption in navigation %}
|
||||
<li class="navitem">
|
||||
<a href="{{ href }}" id="{{ id }}" data-icon="{{ icon }}" class="{{ 'ui-btn-active' if active_page == id }}">{{ caption }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="page-content">{% block content %} {% endblock %}</div>
|
||||
|
||||
<footer class="page-footer">
|
||||
<nav class="navbar">
|
||||
{% for href, id, caption in navigation %}
|
||||
<li class="navbar-item">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
id="{{ id }}"
|
||||
class="{% if active_page == id %}active{% endif %}"
|
||||
>
|
||||
<span class="navbar-icon"></span>
|
||||
<span>{{ caption }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{% block content %}
|
||||
{% block scripts %}
|
||||
<script src="/js/kjua.min.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
{% block script %}{% endblock %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
{% block scripts %}
|
||||
<script type="text/javascript" src="/js/jquery-1.12.4.min.js"></script>
|
||||
<script type="text/javascript" src="/js/jquery.mobile/jquery.mobile-1.4.5.min.js"></script>
|
||||
<script type="text/javascript" src="/js/jquery.timeago.js"></script>
|
||||
<script type="text/javascript" src="/js/kjua-0.9.0.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
$.mobile.ajaxEnabled = false;
|
||||
$.mobile.pushStateEnabled = false;
|
||||
|
||||
jQuery(document).ready(function() {
|
||||
jQuery("time.timeago").timeago();
|
||||
});
|
||||
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
</body>
|
||||
{% endblock %}
|
||||
</body>
|
||||
{% endblock %}
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user