From c675e10d5b6bc19c8ae47d48435ed9e04fbd5218 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Thu, 23 Oct 2025 11:56:03 +0100 Subject: [PATCH 1/5] Add web browse feature --- apps/predbat/web.py | 212 +++++++++++++++++++++++++++++++++++++ apps/predbat/web_helper.py | 188 +++++++++++++++++++++++++++++++- 2 files changed, 399 insertions(+), 1 deletion(-) diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 5ab024557..c763057ed 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -36,6 +36,7 @@ get_entity_css, get_entity_js, get_restart_button_js, + get_browse_css, ) from utils import calc_percent_limit, str2time, dp0, dp2, format_time_ago @@ -166,6 +167,7 @@ async def start(self): app.router.add_get("/api/log", self.html_api_get_log) app.router.add_get("/api/entities", self.html_api_get_entities) app.router.add_post("/api/login", self.html_api_login) + app.router.add_get("/browse", self.html_browse) # Notify plugin system that web interface is ready if hasattr(self.base, "plugin_system") and self.base.plugin_system: @@ -3091,3 +3093,213 @@ async def html_component_restart(self, request): except Exception as e: self.log(f"ERROR: Failed to restart component: {str(e)}") return web.json_response({"success": False, "message": str(e)}, status=500) + + async def html_browse(self, request): + """ + Return the Browse page as an HTML page with file browsing functionality + """ + self.default_page = "./browse" + + # Get query parameters for navigation and file viewing + args = request.query + current_path = args.get('path', '.') + view_file = args.get('file', None) + + # Security check - prevent directory traversal attacks + # Normalize the path and ensure it's within the current working directory + import os.path + base_dir = os.getcwd() + safe_path = os.path.abspath(os.path.join(base_dir, current_path)) + + # Ensure the path is within the base directory + if not safe_path.startswith(base_dir): + safe_path = base_dir + current_path = '.' + + text = self.get_header("Predbat File Browser", refresh=0) + text += "\n" + text += get_browse_css() + + # Breadcrumb navigation + text += '\n' + + if view_file: + # File viewing mode + file_path = os.path.join(safe_path, view_file) if current_path != '.' else view_file + try: + # Security check for file path + file_abs_path = os.path.abspath(file_path) + if not file_abs_path.startswith(base_dir): + raise PermissionError("Access denied") + + if os.path.isfile(file_abs_path): + # Read file content + with open(file_abs_path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + + # Escape HTML content + content = html_module.escape(content) + + # Determine file type for syntax highlighting + file_ext = os.path.splitext(view_file)[1].lower() + + text += f'
\n' + text += f'
\n' + text += f'

Viewing: {view_file}

\n' + text += f'← Back to Directory\n' + text += f'
\n' + + # File content with basic syntax highlighting + syntax_class = "code-content" + if file_ext in ['.yaml', '.yml']: + syntax_class += " yaml" + elif file_ext in ['.py']: + syntax_class += " python" + elif file_ext in ['.json']: + syntax_class += " json" + elif file_ext in ['.log']: + syntax_class += " log" + + text += f'
{content}
\n' + text += f'
\n' + else: + text += f'
File not found: {view_file}
\n' + except PermissionError: + text += f'
Permission denied: {view_file}
\n' + except UnicodeDecodeError: + text += f'
Cannot display binary file: {view_file}
\n' + except Exception as e: + text += f'
Error reading file: {str(e)}
\n' + else: + # Directory listing mode + try: + items = [] + + # Add parent directory link if not at root + if current_path != '.': + parent_path = os.path.dirname(current_path) if current_path != '.' else '.' + if parent_path == '': + parent_path = '.' + items.append({ + 'name': '..', + 'type': 'parent', + 'path': parent_path, + 'size': '', + 'modified': '' + }) + + # List directory contents + for item in os.listdir(safe_path): + item_path = os.path.join(safe_path, item) + + # Skip hidden files and directories + if item.startswith('.') and item not in ['..']: + continue + + try: + stat_info = os.stat(item_path) + size = "" + if os.path.isfile(item_path): + # Format file size + size_bytes = stat_info.st_size + if size_bytes < 1024: + size = f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + size = f"{size_bytes / 1024:.1f} KB" + else: + size = f"{size_bytes / (1024 * 1024):.1f} MB" + + # Format modification time + modified = datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M') + + items.append({ + 'name': item, + 'type': 'directory' if os.path.isdir(item_path) else 'file', + 'path': os.path.join(current_path, item).replace('\\', '/'), + 'size': size, + 'modified': modified + }) + except (OSError, PermissionError): + # Skip items we can't access + continue + + # Sort items: directories first, then files, both alphabetically + items.sort(key=lambda x: (x['type'] != 'parent', x['type'] != 'directory', x['name'].lower())) + + # Display file list + text += '
\n' + text += '\n' + text += '\n' + text += '\n' + text += '\n' + text += '\n' + + for item in items: + icon = "" + link_target = "" + css_class = "" + + if item['type'] == 'parent': + icon = '' + link_target = f"./browse?path={item['path']}" + css_class = "parent-dir" + elif item['type'] == 'directory': + icon = '' + link_target = f"./browse?path={item['path']}" + css_class = "directory" + else: + # File icons based on extension + file_ext = os.path.splitext(item['name'])[1].lower() + if file_ext in ['.yaml', '.yml']: + icon = '' + elif file_ext in ['.py']: + icon = '' + elif file_ext in ['.json']: + icon = '' + elif file_ext in ['.log']: + icon = '' + elif file_ext in ['.txt', '.md']: + icon = '' + else: + icon = '' + + link_target = f"./browse?path={current_path}&file={item['name']}" + css_class = "file" + + text += f'\n' + text += f'\n' + text += f'\n' + text += f'\n' + text += f'\n' + + text += '\n' + text += '
NameSizeModified
{icon} {item["name"]}{item["size"]}{item["modified"]}
\n' + text += '
\n' + + except PermissionError: + text += '
Permission denied accessing directory
\n' + except Exception as e: + text += f'
Error listing directory: {str(e)}
\n' + + text += "\n" + return web.Response(content_type="text/html", text=text) diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index 97a0aaea3..ba16d0fc9 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -4767,14 +4767,15 @@ def get_menu_html(calculating, default_page, arg_errors, THIS_VERSION, battery_s Plan Entities Charts +Compare Config Apps""" + config_warning + """ +Browse Components Editor Log -Compare Docs
""" @@ -4786,3 +4787,188 @@ def get_menu_html(calculating, default_page, arg_errors, THIS_VERSION, battery_s """ ) return text + +def get_browse_css(): + """ + Return CSS styles for the browse page + """ + return """ + + """ \ No newline at end of file From dbc0f3427d951555042f9c3dc24f3c5a646af659 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 10:58:00 +0000 Subject: [PATCH 2/5] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/web.py | 163 +++++++++++++++++-------------------- apps/predbat/web_helper.py | 3 +- 2 files changed, 78 insertions(+), 88 deletions(-) diff --git a/apps/predbat/web.py b/apps/predbat/web.py index c763057ed..fa38c228d 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -3099,89 +3099,90 @@ async def html_browse(self, request): Return the Browse page as an HTML page with file browsing functionality """ self.default_page = "./browse" - + # Get query parameters for navigation and file viewing args = request.query - current_path = args.get('path', '.') - view_file = args.get('file', None) - + current_path = args.get("path", ".") + view_file = args.get("file", None) + # Security check - prevent directory traversal attacks # Normalize the path and ensure it's within the current working directory import os.path + base_dir = os.getcwd() safe_path = os.path.abspath(os.path.join(base_dir, current_path)) - + # Ensure the path is within the base directory if not safe_path.startswith(base_dir): safe_path = base_dir - current_path = '.' - + current_path = "." + text = self.get_header("Predbat File Browser", refresh=0) text += "\n" text += get_browse_css() - + # Breadcrumb navigation text += '\n' - + + text += "
\n" + text += "\n" + if view_file: # File viewing mode - file_path = os.path.join(safe_path, view_file) if current_path != '.' else view_file + file_path = os.path.join(safe_path, view_file) if current_path != "." else view_file try: # Security check for file path file_abs_path = os.path.abspath(file_path) if not file_abs_path.startswith(base_dir): raise PermissionError("Access denied") - + if os.path.isfile(file_abs_path): # Read file content - with open(file_abs_path, 'r', encoding='utf-8', errors='replace') as f: + with open(file_abs_path, "r", encoding="utf-8", errors="replace") as f: content = f.read() - + # Escape HTML content content = html_module.escape(content) - + # Determine file type for syntax highlighting file_ext = os.path.splitext(view_file)[1].lower() - + text += f'
\n' text += f'
\n' - text += f'

Viewing: {view_file}

\n' + text += f"

Viewing: {view_file}

\n" text += f'← Back to Directory\n' - text += f'
\n' - + text += f"
\n" + # File content with basic syntax highlighting syntax_class = "code-content" - if file_ext in ['.yaml', '.yml']: + if file_ext in [".yaml", ".yml"]: syntax_class += " yaml" - elif file_ext in ['.py']: + elif file_ext in [".py"]: syntax_class += " python" - elif file_ext in ['.json']: + elif file_ext in [".json"]: syntax_class += " json" - elif file_ext in ['.log']: + elif file_ext in [".log"]: syntax_class += " log" - + text += f'
{content}
\n' - text += f'\n' + text += f"\n" else: text += f'
File not found: {view_file}
\n' except PermissionError: @@ -3194,28 +3195,22 @@ async def html_browse(self, request): # Directory listing mode try: items = [] - + # Add parent directory link if not at root - if current_path != '.': - parent_path = os.path.dirname(current_path) if current_path != '.' else '.' - if parent_path == '': - parent_path = '.' - items.append({ - 'name': '..', - 'type': 'parent', - 'path': parent_path, - 'size': '', - 'modified': '' - }) - + if current_path != ".": + parent_path = os.path.dirname(current_path) if current_path != "." else "." + if parent_path == "": + parent_path = "." + items.append({"name": "..", "type": "parent", "path": parent_path, "size": "", "modified": ""}) + # List directory contents for item in os.listdir(safe_path): item_path = os.path.join(safe_path, item) - + # Skip hidden files and directories - if item.startswith('.') and item not in ['..']: + if item.startswith(".") and item not in [".."]: continue - + try: stat_info = os.stat(item_path) size = "" @@ -3228,78 +3223,72 @@ async def html_browse(self, request): size = f"{size_bytes / 1024:.1f} KB" else: size = f"{size_bytes / (1024 * 1024):.1f} MB" - + # Format modification time - modified = datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M') - - items.append({ - 'name': item, - 'type': 'directory' if os.path.isdir(item_path) else 'file', - 'path': os.path.join(current_path, item).replace('\\', '/'), - 'size': size, - 'modified': modified - }) + modified = datetime.fromtimestamp(stat_info.st_mtime).strftime("%Y-%m-%d %H:%M") + + items.append({"name": item, "type": "directory" if os.path.isdir(item_path) else "file", "path": os.path.join(current_path, item).replace("\\", "/"), "size": size, "modified": modified}) except (OSError, PermissionError): # Skip items we can't access continue - + # Sort items: directories first, then files, both alphabetically - items.sort(key=lambda x: (x['type'] != 'parent', x['type'] != 'directory', x['name'].lower())) - + items.sort(key=lambda x: (x["type"] != "parent", x["type"] != "directory", x["name"].lower())) + # Display file list text += '
\n' text += '\n' - text += '\n' - text += '\n' - text += '\n' - text += '\n' - + text += "\n" + text += "\n" + text += "\n" + text += "\n" + for item in items: icon = "" link_target = "" css_class = "" - - if item['type'] == 'parent': + + if item["type"] == "parent": icon = '' link_target = f"./browse?path={item['path']}" css_class = "parent-dir" - elif item['type'] == 'directory': + elif item["type"] == "directory": icon = '' link_target = f"./browse?path={item['path']}" css_class = "directory" else: # File icons based on extension - file_ext = os.path.splitext(item['name'])[1].lower() - if file_ext in ['.yaml', '.yml']: + file_ext = os.path.splitext(item["name"])[1].lower() + if file_ext in [".yaml", ".yml"]: icon = '' - elif file_ext in ['.py']: + elif file_ext in [".py"]: icon = '' - elif file_ext in ['.json']: + elif file_ext in [".json"]: icon = '' - elif file_ext in ['.log']: + elif file_ext in [".log"]: icon = '' - elif file_ext in ['.txt', '.md']: + elif file_ext in [".txt", ".md"]: icon = '' else: icon = '' - + link_target = f"./browse?path={current_path}&file={item['name']}" css_class = "file" - + text += f'\n' text += f'\n' text += f'\n' text += f'\n' - text += f'\n' - - text += '\n' - text += '
NameSizeModified
NameSizeModified
{icon} {item["name"]}{item["size"]}{item["modified"]}
\n' - text += '
\n' - + text += f"\n" + + text += "\n" + text += "\n" + text += "\n" + except PermissionError: text += '
Permission denied accessing directory
\n' except Exception as e: text += f'
Error listing directory: {str(e)}
\n' - + text += "\n" return web.Response(content_type="text/html", text=text) diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index ba16d0fc9..d0c41e982 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -4788,6 +4788,7 @@ def get_menu_html(calculating, default_page, arg_errors, THIS_VERSION, battery_s ) return text + def get_browse_css(): """ Return CSS styles for the browse page @@ -4971,4 +4972,4 @@ def get_browse_css(): border-color: #662c34; } - """ \ No newline at end of file + """ From 98912d645da3e1d16fd15869a3c769430cdd48d9 Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:43:03 +0100 Subject: [PATCH 3/5] Update custom-dictionary-workspace.txt --- .cspell/custom-dictionary-workspace.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index e6c62be6c..021d3bdbb 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -41,6 +41,7 @@ coro crosscharge customisation Customise +Consolas cvalue Daeamon darkgreen From cb62cd15ea43a7feb9fdd11c7251a0a953776243 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 12:44:44 +0000 Subject: [PATCH 4/5] [pre-commit.ci lite] apply automatic fixes --- .cspell/custom-dictionary-workspace.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 021d3bdbb..cba06ae96 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -37,11 +37,11 @@ Codespaces collapsable compareform configform +Consolas coro crosscharge customisation Customise -Consolas cvalue Daeamon darkgreen From 19653209c353d8b173e5d4414022b5932a35b365 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Thu, 23 Oct 2025 22:39:18 +0100 Subject: [PATCH 5/5] Style fix --- apps/predbat/web_helper.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index ba16d0fc9..e964e1afb 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -4970,5 +4970,16 @@ def get_browse_css(): color: #f5b5c4; border-color: #662c34; } + +body.dark-mode .back-button { + background-color: #333; + color: #e0e0e0; + border: 1px solid #555; +} + +body.dark-mode .back-button:hover { + background-color: #444; + color: #fff; +} """ \ No newline at end of file