From 8369f43500d7db0c27c802cc2427a43dd436ec29 Mon Sep 17 00:00:00 2001 From: Nico Zanferrari Date: Wed, 18 Feb 2026 19:38:33 +0100 Subject: [PATCH 1/3] _dashboard themes: refactor code structure for improved readability and maintainability --- apps/_dashboard/.gitignore | 1 + apps/_dashboard/DASHBOARD_GUIDE.md | 267 ++++---- apps/_dashboard/THEMES_GUIDE.md | 194 ++++++ apps/_dashboard/__init__.py | 292 ++++++++- apps/_dashboard/static/css/base.css | 507 +++++++++++++++ apps/_dashboard/static/css/future.css | 5 +- apps/_dashboard/static/css/no.css | 97 +++ apps/_dashboard/static/js/index.js | 91 ++- apps/_dashboard/static/js/theme-selector.js | 373 ++++++++--- .../static/{ => themes/AlienDark}/favicon.ico | Bin .../themes/AlienDark/templates/index.html | 299 +++++++++ .../static/themes/AlienDark/theme.css | 62 ++ .../static/themes/AlienDark/theme.js | 15 + .../static/themes/AlienDark/theme.toml | 6 + .../{images => themes/AlienDark}/widget.gif | Bin .../AlienLight/favicon.ico} | Bin .../themes/AlienLight/templates/index.html | 299 +++++++++ .../static/themes/AlienLight/theme.css | 66 +- .../static/themes/AlienLight/theme.js | 50 ++ .../static/themes/AlienLight/theme.toml | 6 + .../AlienLight/widget.gif} | Bin apps/_dashboard/templates/dbadmin.html | 72 ++- apps/_dashboard/templates/index.html | 590 +++++++++--------- apps/_dashboard/templates/layout.html | 7 +- apps/_dashboard/templates/ticket.html | 5 +- 25 files changed, 2766 insertions(+), 538 deletions(-) create mode 100644 apps/_dashboard/.gitignore create mode 100644 apps/_dashboard/THEMES_GUIDE.md create mode 100644 apps/_dashboard/static/css/base.css rename apps/_dashboard/static/{ => themes/AlienDark}/favicon.ico (100%) create mode 100644 apps/_dashboard/static/themes/AlienDark/templates/index.html create mode 100644 apps/_dashboard/static/themes/AlienDark/theme.js create mode 100644 apps/_dashboard/static/themes/AlienDark/theme.toml rename apps/_dashboard/static/{images => themes/AlienDark}/widget.gif (100%) rename apps/_dashboard/static/{favicon_green.ico => themes/AlienLight/favicon.ico} (100%) create mode 100644 apps/_dashboard/static/themes/AlienLight/templates/index.html create mode 100644 apps/_dashboard/static/themes/AlienLight/theme.js create mode 100644 apps/_dashboard/static/themes/AlienLight/theme.toml rename apps/_dashboard/static/{images/widget-transparent.gif => themes/AlienLight/widget.gif} (100%) diff --git a/apps/_dashboard/.gitignore b/apps/_dashboard/.gitignore new file mode 100644 index 000000000..ca5f38ef6 --- /dev/null +++ b/apps/_dashboard/.gitignore @@ -0,0 +1 @@ +user_settings.toml diff --git a/apps/_dashboard/DASHBOARD_GUIDE.md b/apps/_dashboard/DASHBOARD_GUIDE.md index 866cbbcf5..b1cd6c865 100644 --- a/apps/_dashboard/DASHBOARD_GUIDE.md +++ b/apps/_dashboard/DASHBOARD_GUIDE.md @@ -149,154 +149,175 @@ export PY4WEB_PASSWORD_FILE=password.txt ## Theming -The dashboard supports multiple themes using a CSS override pattern. This allows users to switch between themes dynamically without reloading the page, with theme preference persisted in browser localStorage. +The dashboard supports multiple built-in themes (AlienDark, AlienLight) with a fully extensible theming system. Users can switch themes dynamically without page reload, with preferences persisted to the backend. + +**For comprehensive theming documentation**, see [THEMES_GUIDE.md](THEMES_GUIDE.md). + +### Quick Overview + +**Currently Available Themes:** +- **AlienDark** - Modern dark theme with cyan accents (default) +- **AlienLight** - Professional light theme with blue accents + +**Key Features:** +- Dynamic theme discovery from `static/themes/` folder +- CSS custom properties (variables) for styling +- Optional theme-specific JavaScript initialization +- Theme metadata in `theme.toml` +- User preference persisted to `user_settings.toml` ### Theme System Architecture -**Folder Structure:** ``` -static/ -├── css/ -│ ├── future.css # Dark base stylesheet (main dashboard) -│ ├── no.css # Light base stylesheet (dbadmin pages) -│ └── ... -├── js/ -│ ├── theme-selector.js # Theme switching logic -│ └── ... -├── themes/ -│ ├── AlienDark/ -│ │ └── theme.css # Dark theme overrides -│ └── AlienLight/ -│ └── theme.css # Light theme overrides -└── ... +static/themes/{ThemeName}/ +├── theme.toml # Metadata (name, description, author, etc.) +├── theme.css # Styling with CSS variables +├── theme.js # Optional JS initialization +├── favicon.ico # Browser tab icon +├── widget.gif # Loading spinner +└── templates/ # Optional custom templates + ├── index.html + └── ... ``` -### How Theming Works - -1. **Base Stylesheets:** Dashboard loads a base stylesheet (`future.css` for main dashboard, `no.css` for dbadmin) that defines the default dark theme -2. **Theme CSS Variables:** Each theme defines CSS custom properties (variables) for colors and styling -3. **Dynamic Theme Loading:** JavaScript (`theme-selector.js`) dynamically loads theme CSS files by updating the `href` of a `` tag with id `dashboard-theme` -4. **Local Storage Persistence:** Selected theme is stored in browser localStorage under key `py4web-dashboard-theme` -5. **Auto-Apply:** On page load, theme-selector.js automatically applies the saved theme preference - -### Available Themes - -#### AlienDark -- **Description:** Dark theme with cyan accents -- **CSS Variables:** - - `--bg-primary: black` - Main background - - `--text-primary: #d1d1d1` - Primary text color - - `--accent: #33BFFF` - Accent color (cyan) - - `--accent-dark: #007a99` - Dark accent variant - - `--bg-secondary: #1a1a1a` - Secondary background - - `--border-color: #333` - Border color -- **File:** `static/themes/AlienDark/theme.css` - -#### AlienLight -- **Description:** Light theme with blue accents -- **CSS Variables:** - - `--bg-primary: white` - Main background - - `--text-primary: #333` - Primary text color - - `--accent: #0074d9` - Accent color (blue) - - `--accent-dark: #003d74` - Dark accent variant - - `--bg-secondary: #f5f5f5` - Secondary background - - `--border-color: #ddd` - Border color -- **File:** `static/themes/AlienLight/theme.css` - -### Using CSS Variables in Theme Files - -Each theme file uses CSS custom properties for consistent styling across components: - -```css -:root { - --bg-primary: black; - --text-primary: #d1d1d1; - --accent: #33BFFF; - /* ... other variables ... */ -} - -/* Override specific elements using variables */ -body { - background: var(--bg-primary); - color: var(--text-primary); -} - -button { - background: var(--accent); - color: var(--bg-primary); -} -``` +**Frontend System:** +- `js/theme-selector.js` - Manages all client-side theme switching +- Loads theme CSS dynamically by updating `` +- Persists selection to localStorage and backend +- Synchronizes multiple theme selectors on same page + +**Backend System:** +- `get_available_themes()` - Scans `static/themes/` folder +- `normalize_selected_theme()` - Validates stored/requested theme values against available themes +- `load_user_settings()` / `save_user_settings()` - Persists theme selection +- `@action("save_theme")` - HTTP endpoint for theme persistence + +### Adding a Custom Theme + +1. **Create folder:** `static/themes/MyTheme/` +2. **Create `theme.toml`:** + ```toml + name = "My Theme" + description = "Brief description" + version = "1.0.0" + author = "Your Name" + ``` +3. **Create `theme.css`:** + ```css + :root { + --bg-primary: white; + --text-primary: #333; + --accent: #0074d9; + --accent-dark: #0052a3; + --bg-secondary: #f5f5f5; + --border-color: #d1d1d1; + } + body { background: var(--bg-primary); color: var(--text-primary); } + ``` +4. **Optional - Add assets:** `favicon.ico`, `widget.gif`, custom `templates/` +5. **Optional - Add behavior:** `theme.js` for theme-specific JavaScript + +**That's it!** The theme automatically appears in the dropdown. ### Theme Selector UI -The theme selector is visible on the main dashboard (`index.html`): +Dropdowns appear on all dashboard pages: ```html - + [[for theme in themes:]] + + [[pass]] ``` -**Features:** -- Dropdown positioned in top-right corner of header -- Uses `data-theme-selector` attribute for synchronization -- Calls `setDashboardTheme()` function from `theme-selector.js` -- Multiple selectors on the same page stay synchronized +**Multiple selectors on the same page stay synchronized** via `data-theme-selector` attribute. -### JavaScript Theme Switching (theme-selector.js) +### Best Practices -The theme selector module is now fully dynamic, detecting themes from the select element. See [theme-selector.js](static/js/theme-selector.js) for the implementation. +1. **Use CSS Variables** - Define all colors in `:root`, never hardcode +2. **Foundation Pattern** - Override only the styles you need, let defaults cascade +3. **Metadata Matters** - Complete `theme.toml` helps users understand your theme +4. **Test All Pages** - Verify on main dashboard, dbadmin, and other pages +5. **Accessibility** - Ensure sufficient text contrast (WCAG AA minimum) +6. **Dynamic Features** - Use `theme.js` for ACE editor config, custom UI hooks, etc. -**Key Features:** -- `getAvailableThemes()` - Dynamically reads themes from select element options (no hardcoded list) -- `getDefaultTheme()` - Returns "AlienDark" if available, otherwise the first theme alphabetically -- `applyTheme(theme)` - Loads theme CSS and persists selection in localStorage -- Auto-applies saved theme on page load, or default theme if none saved -- Multiple selectors on the same page stay synchronized -- Automatically detects new themes when they're added to the select element +### How Theming Works (Flow Diagram) + +``` +Page loads + ↓ +Backend loads theme from user_settings.toml + ↓ +Renders HTML with SELECTED_THEME variable + ↓ +theme-selector.js runs + ↓ +getStoredTheme() checks: Backend → localStorage → default + ↓ +loadThemeConfig() fetches theme.toml + ↓ +applyTheme() orchestrates: + • Update CSS link href + • Update favicon + • Load theme.js + • Save to localStorage + backend + • Sync all selectors + ↓ +Theme is now active +``` -### Adding a New Theme +### Persistence -To create a new theme (e.g., `MyCustomTheme`): +Theme selection is saved in two places: -1. **Create theme folder:** - ``` - static/themes/MyCustomTheme/ +1. **Backend** - `apps/_dashboard/user_settings.toml` + ```toml + selected_theme = "AlienDark" ``` + Survives browser cache clear / private browsing -2. **Create `theme.css` with CSS variables:** - ```css - :root { - --bg-primary: #your-bg-color; - --text-primary: #your-text-color; - --accent: #your-accent-color; - --accent-dark: #your-accent-dark-color; - --bg-secondary: #your-secondary-bg; - --border-color: #your-border-color; - } - - /* Override styles using variables */ - body { - background: var(--bg-primary); - color: var(--text-primary); - } - /* ... more overrides ... */ - ``` +2. **Browser** - localStorage key: `py4web-dashboard-theme` + Provides immediate fallback if backend unavailable + +**Selection Priority:** +1. Backend setting (from `user_settings.toml`) +2. Browser storage (from localStorage) +3. Default theme (AlienDark if available, else first alphabetically) + +### Troubleshooting Themes + +**Theme not appearing in dropdown:** +- Verify folder exists: `static/themes/MyTheme/` +- Hard refresh browser (Ctrl+Shift+R) +- Restart dashboard to rescan folder + +**CSS not loading:** +- Check Network tab for 4xx errors on `themes/MyTheme/theme.css` +- Verify `:root` block has all required CSS variables +- Check Browser Console for JavaScript errors in theme.js + +**JavaScript not running:** +- Verify `theme.js` is valid with a linter +- Use retry logic if depending on Vue app (may not be ready yet) +- Check Console for syntax errors + +**Settings not persisting:** +- Verify `apps/_dashboard/user_settings.toml` is writable +- Ensure you're logged in (theme save requires `USER_ID`) +- Check Network tab - POST `/save_theme` should return `{"status": "success"}` + +--- -3. **Add to theme selector in templates:** - The theme selector options are now **dynamically generated** from available theme folders in the backend. When you create a new theme folder, it's automatically listed in the select dropdown on both `index.html` and dbadmin pages via Python template iteration. +## Advanced Theming -### Best Practices for Theme Development +For advanced features like: +- Theme-specific templates (custom layouts) +- ACE editor theme configuration +- Favicon management +- Complex initialization patterns -1. **Use CSS Variables:** Always reference theme colors via custom properties, never hardcode colors -2. **Minimal Overrides:** Only include CSS rules that differ from the base stylesheet -3. **Test Both Locations:** Verify theme works on main dashboard (`index.html`) and dbadmin pages (`layout.html`) -4. **Check Full Viewport:** Ensure backgrounds extend to full viewport height, no black/white strips at bottom -5. **Text Contrast:** Verify text colors have sufficient contrast with background colors for accessibility -6. **Form Elements:** Ensure all form inputs (text, select, button, checkbox) are styled consistently +See [THEMES_GUIDE.md - Advanced Patterns](THEMES_GUIDE.md#advanced-patterns) section. --- diff --git a/apps/_dashboard/THEMES_GUIDE.md b/apps/_dashboard/THEMES_GUIDE.md new file mode 100644 index 000000000..51309031d --- /dev/null +++ b/apps/_dashboard/THEMES_GUIDE.md @@ -0,0 +1,194 @@ +# PY4WEB Dashboard Themes Guide + +## Overview + +The `_dashboard` app supports dynamic theme switching with persisted user preference. +Themes are discovered from `static/themes/` and can be switched without a page reload. + +Current built-in themes: +- **AlienDark** (default dark UI) +- **AlienLight** (light UI) + +--- + +## How It Works + +### Runtime Flow + +1. Backend loads `selected_theme` from `user_settings.toml` +2. Backend normalizes it against currently available themes +3. Template renders: + - `themes` list + - `selected_theme` + - `SELECTED_THEME` JavaScript global +4. `static/js/theme-selector.js` initializes and applies the selected theme +5. Selection is stored in: + - backend (`user_settings.toml`) + - browser (`localStorage`) + +### Theme Discovery + +Themes are discovered by scanning folders in: + +`apps/_dashboard/static/themes/` + +Any subdirectory (not starting with `.`) is treated as a theme and appears in the selector. + +--- + +## Theme Folder Structure + +A minimal theme folder: + +```text +static/themes/MyTheme/ +├── theme.toml +├── theme.css +├── theme.js # optional +├── favicon.ico # optional +└── widget.gif # optional +``` + +### `theme.toml` + +Example: + +```toml +name = "MyTheme" +description = "Custom dashboard theme" +version = "1.0.0" +author = "Your Name" +homepage = "https://example.com" +screenshot = "widget.gif" +``` + +Optional keys used by the selector if present: +- `favicon` +- `widget` +- `appFavicon` + +If omitted, defaults are inferred from the current theme folder. + +### `theme.css` + +Use CSS custom properties for consistency: + +```css +:root { + --bg-primary: #ffffff; + --text-primary: #222; + --accent: #0074d9; + --accent-dark: #0052a3; + --bg-secondary: #f5f5f5; + --border-color: #d1d1d1; +} +``` + +### `theme.js` (optional) + +Loaded dynamically when the theme is activated. +Use this for behavior that cannot be expressed in CSS (for example ACE editor integration). + +--- + +## Frontend Components + +Main file: `apps/_dashboard/static/js/theme-selector.js` + +Key responsibilities: +- detect available themes from the selector +- determine initial theme (`SELECTED_THEME` → localStorage → default) +- load `theme.toml` +- swap `theme.css` at runtime +- update favicon/spinner/app icons +- load optional `theme.js` +- persist selection via `POST ../save_theme` +- keep all selectors (`[data-theme-selector]`) synchronized + +Public API: +- `window.setDashboardTheme(theme)` + +--- + +## Backend Components + +Main file: `apps/_dashboard/__init__.py` + +Key functions: +- `get_available_themes()` +- `normalize_selected_theme(selected_theme, available_themes=None)` +- `load_user_settings()` / `save_user_settings()` +- `@action("save_theme", method="POST")` + +### Theme Validation + +Backend normalization guarantees that stale values (for example removed themes) do not break rendering. +If a stored theme is unavailable: +1. use `AlienDark` if present +2. otherwise use the first available theme +3. otherwise fall back to `AlienDark` + +`save_theme` also validates that the requested theme exists. + +--- + +## Template Requirements + +In dashboard templates, keep these elements: + +```html + + + + + +``` + +Important: always bind the CSS link to `selected_theme` from backend context; do not hardcode a theme path. + +--- + +## Creating a Custom Theme + +1. Create `static/themes/MyTheme/` +2. Add `theme.toml` +3. Add `theme.css` +4. Optionally add `theme.js`, `favicon.ico`, `widget.gif` +5. Refresh dashboard and select the theme + +No backend code changes are required. + +--- + +## Troubleshooting + +### Theme not in dropdown +- verify folder exists under `static/themes/` +- verify folder name does not start with `.` +- hard refresh browser + +### Theme not applied on first load +- verify template CSS link uses `selected_theme` +- check rendered HTML for `id="dashboard-theme"` + +### Theme selection not persisted +- ensure user is logged in +- verify `POST ../save_theme` succeeds +- check `apps/_dashboard/user_settings.toml` is writable + +### Theme script not running +- verify `theme.js` exists and has valid JavaScript +- check browser console for syntax/runtime errors + +--- + +## Notes + +- The current dashboard implementation uses a single base layout. +- Theme-specific visual customization is expected to be done primarily through CSS variables and optional `theme.js` behavior. diff --git a/apps/_dashboard/__init__.py b/apps/_dashboard/__init__.py index 26ab02d7c..82d209973 100644 --- a/apps/_dashboard/__init__.py +++ b/apps/_dashboard/__init__.py @@ -5,6 +5,7 @@ import io import json import os +import platform import shutil import subprocess import sys @@ -68,6 +69,91 @@ def wrapper(*args, **kwargs): return wrapper +def change_password_handler(old_password, new_password): + """Change the dashboard password""" + password_file = os.environ.get("PY4WEB_PASSWORD_FILE") + if not password_file: + return {"status": "error", "message": "PY4WEB_PASSWORD_FILE environment variable not set"} + if not os.path.exists(password_file): + return {"status": "error", "message": f"Password file not found: {password_file}"} + + try: + # Read current password from file + with open(password_file, "r") as fp: + stored_password = fp.read().strip() + + # Verify old password matches + if CRYPT()(old_password)[0] != stored_password: + return {"status": "error", "message": "Current password is incorrect"} + + # Encrypt new password + encrypted_password = str(CRYPT()(new_password)[0]) + if not encrypted_password: + return {"status": "error", "message": "Failed to encrypt password"} + + # Write to temporary file first, then rename to avoid data loss + temp_fd, temp_path = tempfile.mkstemp(text=True) + try: + with os.fdopen(temp_fd, 'w') as fp: + fp.write(encrypted_password) + # Only rename if write was successful + shutil.move(temp_path, password_file) + except Exception as temp_error: + # Clean up temp file if it exists + try: + os.unlink(temp_path) + except: + pass + raise temp_error + + return {"status": "success", "message": "Password changed successfully"} + except Exception as e: + return {"status": "error", "message": f"Error: {str(e)}"} + + +def load_user_settings(): + """Load user settings from user_settings.toml""" + settings_file = os.path.join(settings.APP_FOLDER, "user_settings.toml") + default_settings = {"selected_theme": "AlienDark"} + + if not os.path.exists(settings_file): + # Create default settings file + try: + with open(settings_file, "w") as fp: + fp.write('selected_theme = "AlienDark"\n') + return default_settings + except Exception: + return default_settings + + try: + with open(settings_file, "r") as fp: + content = fp.read() + # Simple TOML parser for key = "value" format + parsed_settings = {} + for line in content.split('\n'): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + parsed_settings[key] = value + return parsed_settings if parsed_settings else default_settings + except Exception: + return default_settings + + +def save_user_settings(settings_dict): + """Save user settings to user_settings.toml""" + settings_file = os.path.join(settings.APP_FOLDER, "user_settings.toml") + try: + with open(settings_file, "w") as fp: + for key, value in settings_dict.items(): + fp.write(f'{key} = "{value}"\n') + return {"status": "success"} + except Exception as e: + return {"status": "error", "message": str(e)} + + def get_available_themes(): """Get list of available themes by reading static/themes/ folder""" themes_dir = os.path.join(settings.APP_FOLDER, "static", "themes") @@ -84,7 +170,19 @@ def get_available_themes(): return themes except (OSError, IOError): pass - return ["AlienDark", "AlienLight"] # Fallback + return [] + + +def normalize_selected_theme(selected_theme, available_themes=None): + """Normalize a selected theme against currently available themes.""" + themes = available_themes if available_themes is not None else get_available_themes() + if selected_theme and selected_theme in themes: + return selected_theme + if "AlienDark" in themes: + return "AlienDark" + if themes: + return themes[0] + return "AlienDark" session = Session() @@ -101,15 +199,23 @@ def version(): if MODE in ("demo", "readonly", "full"): @action("index") - @action.uses("index.html", session, T) + @action.uses(session, T, "index.html") def index(): + themes = get_available_themes() + user_settings = load_user_settings() + selected_theme = normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ) + return dict( languages=dumps(getattr(T.local, "language", {})), mode=MODE, user_id=(session.get("user") or {}).get("id"), - themes=get_available_themes(), + themes=themes, + selected_theme=selected_theme, ) + @action("login", method="POST") @action.uses(session) def login(): @@ -133,10 +239,38 @@ def logout(): session["user"] = None return dict() + @action("change_password", method="POST") + @action.uses(Logged(session)) + @catch_errors + def change_password(): + old_password = request.json.get("old_password") + password = request.json.get("password") + if not old_password: + return {"status": "error", "message": "Current password is required"} + if not password: + return {"status": "error", "message": "New password is required"} + return change_password_handler(old_password, password) + + @action("save_theme", method="POST") + @action.uses(Logged(session)) + @catch_errors + def save_theme(): + theme = request.json.get("theme") + if not theme: + return {"status": "error", "message": "Theme name is required"} + themes = get_available_themes() + if theme not in themes: + return {"status": "error", "message": "Theme is not available"} + user_settings = load_user_settings() + user_settings["selected_theme"] = theme + return save_user_settings(user_settings) + @action("tickets/search") @action.uses(Logged(session), "dbadmin.html") def dbadmin(): db = error_logger.database_logger.db + themes = get_available_themes() + user_settings = load_user_settings() def make_grid(): make_safe(db) @@ -157,12 +291,20 @@ def make_grid(): ) grid = action.uses(db)(make_grid)() - return dict(table_name="py4web_error", grid=grid, themes=get_available_themes()) + return dict( + table_name="py4web_error", + grid=grid, + themes=themes, + selected_theme=normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ), + ) @action("dbadmin///") @action.uses(Logged(session), "dbadmin.html") def dbadmin(app_name, db_name, table_name): themes = get_available_themes() + user_settings = load_user_settings() module = Reloader.MODULES.get(app_name) db = getattr(module, db_name) @@ -198,13 +340,27 @@ def make_grid(): return Grid(table, columns=columns) grid = action.uses(db)(make_grid)() - return dict(app_name=app_name, table_name=table_name, grid=grid, themes=themes) + return dict( + app_name=app_name, + table_name=table_name, + grid=grid, + themes=themes, + selected_theme=normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ), + ) @action("info") @session_secured @catch_errors def info(): - vars = [{"name": "python", "version": sys.version}] + # Start with OS information + os_info = f"{platform.system()} {platform.release()}" + vars = [ + {"name": "os", "version": os_info}, + {"name": "py4web", "version": __version__}, + {"name": "python", "version": sys.version} + ] for module in sorted(sys.modules): if not "." in module: try: @@ -234,7 +390,12 @@ def apps(): apps = os.listdir(FOLDER) exposed_names = APP_NAMES and APP_NAMES.split(",") apps = [ - {"name": app, "error": Reloader.ERRORS.get(app)} + { + "name": app, + "error": Reloader.ERRORS.get(app), + "running": app in Reloader.ROUTES and not Reloader.ERRORS.get(app), + "edit_url": URL("app_detail", app), + } for app in apps if os.path.isdir(os.path.join(FOLDER, app)) and not app.startswith("__") @@ -244,6 +405,72 @@ def apps(): apps.sort(key=lambda item: item["name"]) return {"payload": apps, "status": "success"} + @action("app_detail/") + @session_secured + @catch_errors + def app_detail(name): + """Returns a focused payload for app edit layout.""" + app_path = os.path.join(FOLDER, name) + if not os.path.isdir(app_path): + return {"status": "error", "message": "App does not exist"} + + def normalize_path(path): + """Convert Windows backslashes to forward slashes for consistent frontend handling""" + return path.replace(os.sep, '/') + + def list_files_recursive(base_path, relative_path=""): + """Recursively list all folders and their files""" + sections = {} + full_path = os.path.join(base_path, relative_path) if relative_path else base_path + + try: + for item in os.listdir(full_path): + if item.startswith(".") or item == "__pycache__": + continue + + item_path = os.path.join(full_path, item) + # Normalize to forward slashes for frontend + item_relative = os.path.join(relative_path, item) if relative_path else item + item_relative = normalize_path(item_relative) + + if os.path.isdir(item_path): + # Add this directory as a section + files_in_dir = [ + f for f in os.listdir(item_path) + if not f.startswith(".") + and os.path.isfile(os.path.join(item_path, f)) + ] + sections[item_relative] = sorted(files_in_dir) + + # Recursively process subdirectories + subsections = list_files_recursive(base_path, os.path.join(relative_path, item) if relative_path else item) + sections.update(subsections) + elif os.path.isfile(item_path) and not relative_path: + # Root-level files + if "" not in sections: + sections[""] = [] + sections[""].append(item) + except (OSError, IOError): + pass + + return sections + + all_sections = list_files_recursive(app_path) + + # Sort root files if they exist + if "" in all_sections: + all_sections[""] = sorted(all_sections[""]) + + payload = { + "name": name, + "path": app_path, + "error": Reloader.ERRORS.get(name), + "running": name in Reloader.ROUTES and not Reloader.ERRORS.get(name), + "edit_layout": True, + "sections": all_sections, + } + return {"status": "success", "payload": payload} + @action("delete_app/", method="POST") @session_secured @catch_errors @@ -263,14 +490,28 @@ def delete_app(name): @action("new_file//", method="POST") @session_secured def new_file(name, file_name): - """creates a new file""" + """creates a new file or folder""" path = os.path.join(FOLDER, name) - form = request.json + + # Check if this is a folder creation request (query parameter) + is_folder = request.query.get("folder") == "1" + if not os.path.exists(path): return {"status": "success", "payload": "App does not exist"} + full_path = os.path.join(path, file_name) if not full_path.startswith(path + os.sep): return {"status": "success", "payload": "Invalid path"} + + # Create folder + if is_folder: + try: + os.makedirs(full_path, exist_ok=True) + return {"status": "success"} + except Exception as e: + return {"status": "error", "message": str(e)} + + # File creation (existing logic) if os.path.exists(full_path): return {"status": "success", "payload": "File already exists"} parent = os.path.dirname(full_path) @@ -390,14 +631,20 @@ def clear_tickets(): @action.uses("ticket.html") @session_secured def error_ticket(ticket_uuid): + themes = get_available_themes() + user_settings = load_user_settings() + selected_theme = normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ) if MODE != "demo": return dict( ticket=safely( lambda: error_logger.database_logger.get(ticket_uuid=ticket_uuid) - ) + ), + selected_theme=selected_theme ) else: - return dict(ticket=None) + return dict(ticket=None, selected_theme=selected_theme) @action("rest/", method=["GET", "POST", "PUT", "DELETE"]) @session_secured @@ -497,8 +744,15 @@ def save(path, reload_app=True): @session_secured @catch_errors def delete(path): - """Deletes a file""" + """Deletes a file or empty folder""" fullpath = safe_join(FOLDER, path) or abort() + + # Check if it's a directory + if os.path.isdir(fullpath): + # Only allow deletion if directory is empty + if os.listdir(fullpath): + return {"status": "error", "message": "Cannot delete non-empty folder. Delete all files first."} + recursive_unlink(fullpath) return {"status": "success"} @@ -581,6 +835,8 @@ def new_app(): @action.uses(Logged(session), "gitlog.html") @catch_errors def gitlog(project): + themes = get_available_themes() + user_settings = load_user_settings() if not is_git_repo(os.path.join(FOLDER, project)): return "Project is not a GIT repo" branches = get_branches(cwd=os.path.join(FOLDER, project)) @@ -591,6 +847,9 @@ def gitlog(project): checkout=checkout, project=project, branches=branches, + selected_theme=normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ), ) @authenticated.callback() @@ -635,11 +894,18 @@ def gitshow(project, commit): @action.uses(Logged(session), "translations.html") def translations(name): """returns a json with all translations for all languages""" + themes = get_available_themes() + user_settings = load_user_settings() folder = os.path.join(FOLDER, name, "translations") if not os.path.exists(folder): os.makedirs(folder) t = Translator(folder) - return t.languages + return dict( + languages=t.languages, + selected_theme=normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ), + ) @action("api/translations/", method="GET") diff --git a/apps/_dashboard/static/css/base.css b/apps/_dashboard/static/css/base.css new file mode 100644 index 000000000..9b2116905 --- /dev/null +++ b/apps/_dashboard/static/css/base.css @@ -0,0 +1,507 @@ +/* ============================================================= + base.css - Dashboard Base Styles + + This file provides common, theme-agnostic styles for the + py4web Dashboard. All layouts, spacing, form structure, + and typography are defined here. Theme-specific colors, + backgrounds, and accents are defined in theme.css files. + + CSS Variables (defined by each theme): + - --bg-primary, --bg-secondary, --bg-tertiary + - --text-primary, --text-secondary, --text-light + - --accent-color, --accent-hover + - --border-color, --border-light + - --header-bg, --button-bg, --input-bg + - etc. + ============================================================= */ + +/******* Reset & Global *******/ +* { + border: 0; + margin: 0; + padding: 0; + box-sizing: inherit; + color: inherit; +} + +html, body { + max-width: 100vw; + overflow-x: hidden; + box-sizing: border-box; + font-size: 14px; +} + +body { + font-family: "Roboto", Helvetica, Arial, sans-serif; + line-height: 1.8em; + min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr auto; +} + +[v-cloak] { display: none; } + +/******* Typography *******/ +h1, h2, h3, h4, h5, h6 { + font-weight: bold; + line-height: 1em; + padding: 5px 10px; + width: 100%; + margin-bottom: 0.5em; +} + +h1 { font-size: 4em; margin: 1.0em 0 0.25em 0; } +h2 { font-size: 2.4em; margin: 0.9em 0 0.25em 0; } +h3 { font-size: 1.8em; margin: 0.8em 0 0.25em 0; } +h4 { font-size: 1.6em; margin: 0.7em 0 0.30em 0; } +h5 { font-size: 1.4em; margin: 0.6em 0 0.40em 0; } +h6 { font-size: 1.2em; margin: 0.5em 0 0.50em 0; } + +p { + margin-bottom: 10px; + text-align: justify; +} + +b, label, strong { + font-weight: bold; +} + +a { + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +a:hover { + cursor: pointer; +} + +em, i { + font-style: italic; +} + +code { + border-radius: 0.4rem; + font-size: 90%; + margin: 0 0.2rem; + padding: 0.2rem 0.5rem; + white-space: nowrap; + font-family: monospace; +} + +blockquote { + padding: 10px 20px; + margin: 10px 0; + border-left: 4px solid; +} + +ul { + list-style-type: none; + padding-left: 20px; +} + +li { + margin-bottom: 0.5em; +} + +/******* Layout *******/ +header, footer { + display: block; + width: 100%; +} + +body > header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + gap: 10px; +} + +body > footer { + padding: 20px; + text-align: center; + font-size: 0.9em; +} + +body > main { + padding: 20px; + overflow-y: auto; +} + +/******* Flexbox Grid Utilities *******/ +.columns { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.columns > * { + flex: 1; + min-width: 200px; +} + +.col { + display: flex; + flex-direction: column; +} + +.c25 { width: calc(25% - 8px); } +.c33 { width: calc(33.333% - 7px); } +.c50 { width: calc(50% - 5px); } +.c66 { width: calc(66.666% - 7px); } +.c75 { width: calc(75% - 8px); } + +/******* Table Styles *******/ +table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1.0rem; +} + +thead { + display: table-header-group; +} + +tbody { + display: table-row-group; +} + +tbody tr:hover { + background-color: opacity; + transition: background-color 0.2s; +} + +thead tr { + border-bottom: 2px solid; +} + +tbody tr { + border-bottom: 1px solid; +} + +td, th { + padding: 4px 8px; + text-align: left; + vertical-align: top; +} + +thead th { + vertical-align: bottom; + font-weight: bold; + white-space: nowrap; +} + +td.middle { + vertical-align: middle; +} + +/******* Button Styles *******/ +[role="button"], +button, +input[type='button'], +input[type='reset'], +input[type='submit'] { + border-radius: 5px; + margin-right: 5px; + margin-bottom: 5px; + cursor: pointer; + display: inline-block; + font-size: 1rem; + font-weight: 300; + height: 1.8rem; + line-height: 1.8rem; + padding: 0 1.0rem; + text-align: center; + text-decoration: none; + white-space: nowrap; + min-width: 50px; + border: 2px solid; + transition: all 0.4s ease; + box-sizing: border-box; +} + +[role="button"]:focus, +[role="button"]:hover, +button:focus, +button:hover, +input[type='button']:focus, +input[type='button']:hover, +input[type='reset']:focus, +input[type='reset']:hover, +input[type='submit']:focus, +input[type='submit']:hover { + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); +} + +button.btn-app { + width: 200px; + padding: 4px 20px 6px 10px; + text-align: left; +} + +/******* Form Styles *******/ +input[type='color'], +input[type='date'], +input[type='datetime'], +input[type='time'], +input[type='datetime-local'], +input[type='email'], +input[type='month'], +input[type='number'], +input[type='password'], +input[type='search'], +input[type='tel'], +input[type='text'], +input[type='url'], +input[type='week'], +input:not([type]), +textarea, +select { + border: 0.1rem solid; + border-radius: 5px; + box-shadow: none; + box-sizing: inherit; + font-family: monospace; + font-size: 1em; + padding: 0.5em 1.0em; + width: 100%; + width: -moz-available; + width: -webkit-fill-available; + margin-bottom: 1.0rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +input[type='color']:focus, +input[type='date']:focus, +input[type='time']:focus, +input[type='datetime']:focus, +input[type='datetime-local']:focus, +input[type='email']:focus, +input[type='month']:focus, +input[type='number']:focus, +input[type='password']:focus, +input[type='search']:focus, +input[type='tel']:focus, +input[type='text']:focus, +input[type='url']:focus, +input[type='week']:focus, +input:not([type]):focus, +textarea:focus, +select:focus { + outline: 0; +} + +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + padding-right: 2em; + background-size: 1.5em; + background-position: center right 8px; + background-repeat: no-repeat; +} + +select[multiple] { + background: none; + height: auto; +} + +textarea { + min-height: 6.5rem; + resize: vertical; +} + +fieldset { + border-width: 0; + padding: 0; + margin-bottom: 1.0rem; +} + +legend { + font-weight: bold; + margin-bottom: 0.5em; +} + +label { + display: block; + margin-bottom: 0.5em; +} + +/******* Panel Styles *******/ +.panel { + background: transparent; + text-align: left; + vertical-align: top; + border: 0; + padding: 0; + margin: 5px 15px 5px 5px; + padding: 30px 5px 2px 5px; + position: relative; + text-overflow: ellipsis; +} + +.panel > label { + width: 98%; + white-space: nowrap; + font-size: 24px; + font-weight: normal; + border: 4px solid; + color: black; + padding: 2px 6px; + opacity: 1.0; + position: absolute; + top: 0; + left: 0; +} + +/******* Accordion *******/ +.accordion > * { + margin-bottom: 10px; +} + +.accordion > label { + cursor: pointer; + display: block; + font-weight: bold; + padding: 10px; + user-select: none; +} + +.accordion > div { + max-height: 500px; + overflow: hidden; + transition: max-height 0.3s; +} + +.accordion > div.close { + max-height: 0; +} + +/******* Tags List *******/ +.tag { + border-radius: 5px; + padding: 3px 10px; + margin-right: 5px; + margin-bottom: 5px; + font-size: 14px; + display: inline-block; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +/******* Scrolling *******/ +.scrollx { + overflow: auto; +} + +iframe { + width: 100%; + height: 90vh; + background-color: white; +} + +/******* Code Editor *******/ + +/******* Highlights *******/ + +/******* Utilities *******/ +.fill { + width: 100%; + height: 100%; +} + +.padded { + padding: 20px; +} + +.hidden { + display: none !important; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.header-left { + display: flex; + align-items: center; +} + +.header-right { + display: flex; + align-items: center; + gap: 8px; + padding-right: 140px; +} + +.spinner-top { + height: 80px; + padding: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.logo { + padding: 0 0 0 8px; + font-size: 64px; + position: static; + line-height: 1; + display: inline-block; +} + +/******* Responsive Design *******/ +@media (max-width: 768px) { + body { + font-size: 13px; + } + + .header-right { + padding-right: 80px; + } + + .columns { + flex-direction: column; + } + + .columns > * { + min-width: 100%; + } + + .c25, .c33, .c50, .c66, .c75 { + width: 100%; + } + + table { + font-size: 0.9em; + } + + td, th { + padding: 2px 4px; + } +} + +@media (max-width: 480px) { + body { + font-size: 12px; + } + + h1 { font-size: 2em; } + h2 { font-size: 1.5em; } + h3 { font-size: 1.2em; } + + button, + input[type='button'], + input[type='reset'], + input[type='submit'] { + min-width: 40px; + padding: 0 0.5rem; + } +} diff --git a/apps/_dashboard/static/css/future.css b/apps/_dashboard/static/css/future.css index d7cbf1644..f7dbf15ce 100644 --- a/apps/_dashboard/static/css/future.css +++ b/apps/_dashboard/static/css/future.css @@ -27,6 +27,9 @@ p { height: 80px; padding: 10px; } +.header-right { + align-items: center; +} .ace-pastel-on-dark { background-color: black !important; } @@ -252,7 +255,7 @@ label { right:0; height: 100vh; background-color: black; - background-image: url(../images/widget.gif); + background-image: url(../themes/AlienDark/widget.gif); background-repeat: no-repeat; background-position: center center; } diff --git a/apps/_dashboard/static/css/no.css b/apps/_dashboard/static/css/no.css index c3aa291fc..d0758d2f7 100644 --- a/apps/_dashboard/static/css/no.css +++ b/apps/_dashboard/static/css/no.css @@ -600,6 +600,103 @@ nav { width: auto; } .padded { max-width: unset; padding-top: 0; } +/* DBAdmin Page: Header Layout for All Themes */ +body.dbadmin-page .header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 10px 20px; +} + +body.dbadmin-page .header-left { + display: flex; + align-items: center; + gap: 10px; +} + +body.dbadmin-page .header-right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +body.dbadmin-page .header-theme { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +body.dbadmin-page .header-theme label { + font-size: 12px; + margin: 0; +} + +body.dbadmin-page .header-theme select { + width: 140px; + height: auto; + padding: 4px 8px; + margin: 0; +} + +body.dbadmin-page .spinner-top { + height: 64px; + width: 64px; + padding: 0; +} + +body.dbadmin-page .logo { + font-size: 32px; + line-height: 1.1; + padding: 0; +} + +body.dbadmin-page { + display: block; + height: 100vh; + overflow: hidden; +} + +@supports (height: 100dvh) { + body.dbadmin-page { + height: 100dvh; + } +} + +body.dbadmin-page .my-effects { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +@supports (height: 100dvh) { + body.dbadmin-page .my-effects { + height: 100%; + } +} + +body.dbadmin-page .dbadmin { + flex: 1 1 auto; + min-height: 0; + display: flex; + overflow: hidden; +} + +body.dbadmin-page .dbadmin .dbadmin-content { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +body.dbadmin-page footer { + flex: 0 0 auto; +} + .dbadmin { width: 100%;} .dbadmin .grid-search-form {width:100%; max-width: 100%} .dbadmin tbody tr:hover { background-color:#002233 } diff --git a/apps/_dashboard/static/js/index.js b/apps/_dashboard/static/js/index.js index 9b6ab5f15..e88fcdce5 100644 --- a/apps/_dashboard/static/js/index.js +++ b/apps/_dashboard/static/js/index.js @@ -58,7 +58,8 @@ const app = Vue.createApp({ modal: null, editor: null, modelist: null, - last_error: "" + last_error: "", + show_system_info: false }; }, @@ -74,21 +75,26 @@ const app = Vue.createApp({ to_json(r) { let json = {}; try { - console.log(r.data); json = r.json(); - console.log(json); } catch (e) { app.vue.last_error = "Invalid JSON:\n" + r.data; return {}; } - if (json.status === "error") { app.vue.last_error = json.traceback; return {}; } + if (json.status === "error") { + app.vue.last_error = json.traceback || json.message || "Unknown error"; + return json; // Return json to preserve message field + } return json; }, activate_editor(path, payload) { this.files[path] = payload; if (!this.editor) { this.editor = ace.edit("editor"); - this.editor.setTheme("ace/theme/pastel_on_dark"); + var dashboardTheme = document.documentElement.getAttribute("data-theme") || ""; + var aceTheme = dashboardTheme === "AlienLight" + ? "ace/theme/chrome" + : "ace/theme/pastel_on_dark"; + this.editor.setTheme(aceTheme); this.editor.$blockScrolling = Infinity; } this.editor.session.setValue(payload); @@ -133,6 +139,72 @@ const app = Vue.createApp({ this.modal = null; }, + change_password() { + if (!this.modal || !this.modal.form) return; + const old_pwd = this.modal.form.old_password; + const pwd = this.modal.form.password; + const pwd_confirm = this.modal.form.password_confirm; + + if (!old_pwd) { + alert('Current password is required'); + return; + } + if (!pwd) { + alert('New password cannot be empty'); + return; + } + if (pwd !== pwd_confirm) { + alert('Passwords do not match'); + return; + } + + Q.post('../change_password', { old_password: old_pwd, password: pwd }) + .then(r => { + const response = this.to_json(r); + if (response.status === 'success') { + alert('Password changed successfully'); + this.modal_dismiss(); + } else { + alert('Failed to change password: ' + (response.message || 'Unknown error')); + } + }) + .catch((e) => { + alert('Error changing password: ' + e); + }); + }, + + open_change_password_modal() { + this.modal = { + title: 'Change Dashboard Password', + color: 'orange', + message: 'Enter your current password and new password', + form_name: 'change-password', + form: {old_password: '', password: '', password_confirm: ''}, + buttons: [ + {text: 'Change', onclick: () => {this.change_password();}}, + {text: 'Cancel', onclick: () => {this.modal_dismiss();}} + ] + }; + }, + + copy_system_info() { + if (!this.info || this.info.length === 0) return; + + // Format system info as text + let text = 'System Information\n'; + text += '==================\n\n'; + for (let i = 0; i < this.info.length; i++) { + text += this.info[i].name + ': ' + this.info[i].version + '\n'; + } + + // Copy to clipboard + navigator.clipboard.writeText(text).then(() => { + alert('System information copied to clipboard'); + }).catch(() => { + alert('Failed to copy to clipboard'); + }); + }, + reload(name) { this.modal_dismiss(); this.loading = true; @@ -182,7 +254,6 @@ const app = Vue.createApp({ process_new_app() { let form = this.modal.form; - console.log(form); if(!form.name) alert('An app name must be provided'); else if(!form.name.match(/^[\w_]+$/)) alert('Invalid file name'); else if(form.mode=='new' && this.apps.map((a)=>{return a.name;}).indexOf(form.name)>=0) { @@ -334,6 +405,14 @@ const app = Vue.createApp({ this.reload_tickets(); this.reload_files(); setTimeout(()=>{this.loading=false;}, 1000); + }, + + handleEdit(appName) { + const selected = this.apps.filter((appItem) => appItem.name === appName)[0] || null; + if (!selected) { + return; + } + this.select(selected); } }, diff --git a/apps/_dashboard/static/js/theme-selector.js b/apps/_dashboard/static/js/theme-selector.js index 8d71b03be..0735f977f 100644 --- a/apps/_dashboard/static/js/theme-selector.js +++ b/apps/_dashboard/static/js/theme-selector.js @@ -16,6 +16,99 @@ "use strict"; var STORAGE_KEY = "py4web-dashboard-theme"; + var CONFIG_CACHE = {}; + var ACTIVE_THEME = null; + var ACTIVE_THEME_CONFIG = null; + var DEFAULT_THEME_CONFIG = { + favicon: "themes/AlienDark/favicon.ico", + widget: "themes/AlienDark/widget.gif", + appFavicon: "", + description: "", + version: "", + author: "", + homepage: "", + screenshot: "" + }; + + function resolveAssetUrl(path) { + try { + return new URL(path, document.baseURI).toString(); + } catch (err) { + return path; + } + } + + function applyThemeToAppIcons(parent, config) { + if (!config || !config.appFavicon) { + return; + } + var appIconUrl = resolveAssetUrl(config.appFavicon); + var scope = parent && typeof parent.querySelectorAll === "function" ? parent : document; + var icons = scope.querySelectorAll("button.btn-app img[src*='favicon']"); + for (var i = 0; i < icons.length; i += 1) { + var icon = icons[i]; + if (!icon.getAttribute("data-original-src")) { + icon.setAttribute("data-original-src", icon.getAttribute("src")); + } + if (icon.getAttribute("src") !== appIconUrl) { + icon.setAttribute("src", appIconUrl); + } + } + } + + function parseThemeToml(text) { + var config = {}; + var lines = text.split(/\r?\n/); + for (var i = 0; i < lines.length; i += 1) { + var line = lines[i].trim(); + if (!line || line[0] === "#") { + continue; + } + var parts = line.split("="); + if (parts.length < 2) { + continue; + } + var key = parts[0].trim(); + var raw = parts.slice(1).join("=").trim(); + if ((raw[0] === '"' && raw[raw.length - 1] === '"') || (raw[0] === "'" && raw[raw.length - 1] === "'")) { + raw = raw.slice(1, -1); + } + config[key] = raw; + } + return config; + } + + function loadThemeConfig(theme) { + if (CONFIG_CACHE[theme]) { + return Promise.resolve(CONFIG_CACHE[theme]); + } + return fetch("themes/" + theme + "/theme.toml", { cache: "no-cache" }) + .then(function (response) { + if (!response.ok) { + throw new Error("Theme config not found"); + } + return response.text(); + }) + .then(function (text) { + var parsed = parseThemeToml(text); + var merged = Object.assign({}, DEFAULT_THEME_CONFIG, parsed); + if (!parsed.favicon) { + merged.favicon = "themes/" + theme + "/favicon.ico"; + } + if (!parsed.widget) { + merged.widget = "themes/" + theme + "/widget.gif"; + } + if (!parsed.appFavicon) { + merged.appFavicon = "themes/" + theme + "/favicon.ico"; + } + CONFIG_CACHE[theme] = merged; + return merged; + }) + .catch(function () { + CONFIG_CACHE[theme] = DEFAULT_THEME_CONFIG; + return DEFAULT_THEME_CONFIG; + }); + } /** * Retrieves all available themes by reading options from the theme selector dropdown. @@ -49,25 +142,39 @@ */ function getDefaultTheme() { var themes = getAvailableThemes(); + if (themes.length === 0) { + return null; + } // Prefer AlienDark if available, otherwise use first alphabetically if (themes.indexOf("AlienDark") !== -1) { return "AlienDark"; } - return themes.length > 0 ? themes[0] : "AlienDark"; + var fallback = themes.length > 0 ? themes[0] : "AlienDark"; + return fallback; } /** - * Retrieves the previously stored theme from browser localStorage, if valid. - * Validates that the stored theme is still in the available themes list - * (handles case where a theme was removed after being selected). + * Retrieves the previously stored theme, prioritizing backend settings. + * Order of preference: + * 1. SELECTED_THEME from backend (set in user_settings.toml) + * 2. localStorage (browser fallback) + * 3. null (will use default theme) * - * @returns {string|null} The stored theme name if valid and localStorage accessible, - * null otherwise (will fallback to default theme) + * @returns {string|null} The stored theme name if valid, null otherwise */ function getStoredTheme() { + var themes = getAvailableThemes(); + + // First check backend-provided theme (from user_settings.toml) + if (typeof SELECTED_THEME !== 'undefined' && SELECTED_THEME) { + if (themes.indexOf(SELECTED_THEME) !== -1) { + return SELECTED_THEME; + } + } + + // Fallback to localStorage try { var stored = localStorage.getItem(STORAGE_KEY); - var themes = getAvailableThemes(); if (stored && themes.indexOf(stored) !== -1) { return stored; } @@ -77,6 +184,50 @@ return null; } + /** + * Dynamically loads theme-specific JavaScript file if it exists. + * Themes can provide custom JavaScript code that executes when the theme is activated. + * This allows themes to modify behavior, initialize custom components, etc. + * + * @param {string} theme - The theme name to load JavaScript for + */ + function loadThemeScript(theme) { + var scriptPath = "themes/" + theme + "/theme.js"; + + // Check if the script is already loaded to avoid duplicates + var existingScript = document.querySelector('script[data-theme-script="' + theme + '"]'); + if (existingScript) { + // Script already loaded, don't load again + return; + } + + // Remove any previously loaded theme scripts (from other themes) + var oldScripts = document.querySelectorAll('script[data-theme-script]'); + for (var i = 0; i < oldScripts.length; i += 1) { + var oldScript = oldScripts[i]; + if (oldScript.getAttribute("data-theme-script") !== theme) { + oldScript.parentNode.removeChild(oldScript); + } + } + + // Create and inject the script + var script = document.createElement('script'); + script.setAttribute('data-theme-script', theme); + script.src = scriptPath + "?t=" + new Date().getTime(); // Cache bust + script.defer = false; + + // Optionally handle script load/error events + script.onload = function () { + // Theme script loaded successfully + }; + + script.onerror = function () { + // Theme script doesn't exist or failed to load - that's OK, not all themes have JS + }; + + document.head.appendChild(script); + } + /** * Applies a theme by: * 1. Validating the requested theme against available options @@ -94,87 +245,81 @@ var defaultTheme = getDefaultTheme(); var selected = themes.indexOf(theme) !== -1 ? theme : defaultTheme; - // Load theme CSS file by updating the link element href - var link = document.getElementById("dashboard-theme"); - if (link) { - link.setAttribute("href", "themes/" + selected + "/theme.css"); - } - - // Update browser favicon based on theme - var favicon = document.querySelector("link[rel='shortcut icon']"); - if (favicon) { - if (selected === "AlienLight") { - favicon.setAttribute("href", "favicon_green.ico"); - } else { - favicon.setAttribute("href", "favicon.ico"); + loadThemeConfig(selected).then(function (config) { + // Load theme CSS file by updating the link element href + var link = document.getElementById("dashboard-theme"); + if (link) { + var newHref = "themes/" + selected + "/theme.css?v=" + new Date().getTime(); + link.setAttribute("href", newHref); + } + + // Update browser favicon based on theme config + var favicon = document.querySelector("link[rel='shortcut icon']"); + if (favicon) { + var faviconUrl = config.favicon || DEFAULT_THEME_CONFIG.favicon; + favicon.setAttribute("href", faviconUrl); } - } - // Update the top-left spinner image for light theme - var spinner = document.querySelector("img.spinner-top"); - if (spinner) { - var originalSpinnerSrc = spinner.getAttribute("data-original-src"); - if (!originalSpinnerSrc) { - originalSpinnerSrc = spinner.getAttribute("src"); - spinner.setAttribute("data-original-src", originalSpinnerSrc); + // Update the top-left spinner image based on theme config + var spinner = document.querySelector("img.spinner-top"); + if (spinner) { + var originalSpinnerSrc = spinner.getAttribute("data-original-src"); + if (!originalSpinnerSrc) { + originalSpinnerSrc = spinner.getAttribute("src"); + spinner.setAttribute("data-original-src", originalSpinnerSrc); + } + var spinnerUrl = config.widget || originalSpinnerSrc; + spinner.setAttribute("src", spinnerUrl); } - if (selected === "AlienLight") { - spinner.setAttribute("src", "images/widget-transparent.gif"); - } else { - spinner.setAttribute("src", originalSpinnerSrc); + + ACTIVE_THEME = selected; + ACTIVE_THEME_CONFIG = config; + applyThemeToAppIcons(document, config); + + // Set data attribute for CSS selectors that might use it + document.documentElement.setAttribute("data-theme", selected); + + // Dynamically load theme-specific JavaScript if it exists + loadThemeScript(selected); + + // Persist theme selection to localStorage (for immediate fallback) + try { + localStorage.setItem(STORAGE_KEY, selected); + } catch (err) { } - } - - // Update app icons based on theme (images with favicon.ico src) - var appIcons = document.querySelectorAll("img[src*='favicon']"); - for (var i = 0; i < appIcons.length; i += 1) { - var img = appIcons[i]; - var currentSrc = img.getAttribute("src"); - // Skip if not a favicon icon - if (!currentSrc.includes("favicon")) continue; + // Persist theme selection to backend (user_settings.toml) + saveThemeToBackend(selected); - if (selected === "AlienLight") { - // Store original src if not already stored - if (!img.getAttribute("data-original-src")) { - img.setAttribute("data-original-src", currentSrc); - } - // Point all app icons to the dashboard's green favicon - img.setAttribute("src", "/_dashboard/static/favicon_green.ico"); - } else { - // Restore original favicon path - var originalSrc = img.getAttribute("data-original-src"); - if (originalSrc && originalSrc !== "/_dashboard/static/favicon_green.ico") { - // Use stored original - img.setAttribute("src", originalSrc); - } else if (currentSrc.includes("_dashboard") && currentSrc.includes("favicon_green")) { - // Currently pointing to green, extract the original app path - // For dashboard: /static/favicon.ico or /{app}/static/favicon.ico - var parts = document.location.pathname.split("/"); - if (parts[1] && parts[1] !== "_dashboard") { - img.setAttribute("src", "/" + parts[1] + "/static/favicon.ico"); - } else { - img.setAttribute("src", "/static/favicon.ico"); - } - } else if (currentSrc.includes("favicon_green")) { - // Try to reconstruct original from URL pattern - img.setAttribute("src", "/static/favicon.ico"); - } + // Update all theme selector dropdowns to reflect current theme + syncSelectors(selected); + if (typeof SELECTED_THEME !== 'undefined') { + SELECTED_THEME = selected; } + }); + } + + /** + * Saves theme selection to backend via save_theme endpoint. + * Silently fails if backend is unavailable or user not logged in. + * + * @param {string} theme - The theme name to save + */ + function saveThemeToBackend(theme) { + // Only save if user is logged in (USER_ID is defined) + if (typeof USER_ID === 'undefined' || !USER_ID) { + return; } - // Set data attribute for CSS selectors that might use it - document.documentElement.setAttribute("data-theme", selected); - - // Persist theme selection to localStorage - try { - localStorage.setItem(STORAGE_KEY, selected); - } catch (err) { - // Ignore storage errors (private browsing, full storage, etc.) - } - - // Update all theme selector dropdowns to reflect current theme - syncSelectors(selected); + fetch('../save_theme', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ theme: theme }) + }).catch(function () { + // Silently ignore errors (e.g., not logged in, network issues) + }); } /** @@ -193,20 +338,76 @@ /** * Initializes the theme system on page load. * Stores original favicon src values before applying any theme. + * Watches for dynamically-rendered icons (from Vue) and initializes them. * Loads the previously saved theme, or applies the default if none saved. */ function init() { - // Store all original favicon srcs before applying theme - var appIcons = document.querySelectorAll("img[src*='favicon']"); - for (var i = 0; i < appIcons.length; i += 1) { - var img = appIcons[i]; - var currentSrc = img.getAttribute("src"); - if (currentSrc && !img.getAttribute("data-original-src")) { - img.setAttribute("data-original-src", currentSrc); + var themes = getAvailableThemes(); + if (themes.length === 0) { + throw new Error("No dashboard themes found. Add a theme folder under static/themes."); + } + + // Helper function to initialize favicon icons with proper tracking + var initFaviconIcons = function(parent) { + var appIcons = parent.querySelectorAll("img[src*='favicon']"); + for (var i = 0; i < appIcons.length; i += 1) { + var img = appIcons[i]; + var currentSrc = img.getAttribute("src"); + // Ensure data-original-src is set (either from attribute or from current src) + if (!img.getAttribute("data-original-src") && currentSrc) { + img.setAttribute("data-original-src", currentSrc); + } } + }; + + // Initialize static icons + initFaviconIcons(document); + + // Watch for dynamically-rendered icons and initialize them + // This handles Vue.js rendering the app list after the script loads + if (typeof MutationObserver !== 'undefined') { + var observer = new MutationObserver(function(mutations) { + // Check if any added nodes contain favicon images + for (var i = 0; i < mutations.length; i += 1) { + var mutation = mutations[i]; + if (mutation.addedNodes.length > 0) { + for (var j = 0; j < mutation.addedNodes.length; j += 1) { + var node = mutation.addedNodes[j]; + // Only process element nodes + if (node.nodeType === 1) { + // Check if the node itself is an img with favicon + if (node.tagName === 'IMG' && node.src && node.src.includes('favicon')) { + if (!node.getAttribute('data-original-src')) { + node.setAttribute('data-original-src', node.getAttribute('src')); + } + if (ACTIVE_THEME_CONFIG) { + applyThemeToAppIcons(node.parentNode || document, ACTIVE_THEME_CONFIG); + } + } + // Check if the node contains img children with favicon + if (typeof node.querySelectorAll === 'function') { + initFaviconIcons(node); + if (ACTIVE_THEME_CONFIG) { + applyThemeToAppIcons(node, ACTIVE_THEME_CONFIG); + } + } + } + } + } + } + }); + + // Watch the document body for changes + observer.observe(document.body, { + childList: true, + subtree: true + }); } var initial = getStoredTheme() || getDefaultTheme(); + if (!initial) { + throw new Error("Unable to determine a default dashboard theme."); + } applyTheme(initial); } diff --git a/apps/_dashboard/static/favicon.ico b/apps/_dashboard/static/themes/AlienDark/favicon.ico similarity index 100% rename from apps/_dashboard/static/favicon.ico rename to apps/_dashboard/static/themes/AlienDark/favicon.ico diff --git a/apps/_dashboard/static/themes/AlienDark/templates/index.html b/apps/_dashboard/static/themes/AlienDark/templates/index.html new file mode 100644 index 000000000..a1474a744 --- /dev/null +++ b/apps/_dashboard/static/themes/AlienDark/templates/index.html @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + +
+
+ + +
+
+
+

API error

+

+            
+        
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
{{selected_app.error}}
+
+
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
RuleMethodFilenameActionTime(s)Calls/sErrors/s
{{route.rule}}{{route.method}}{{route.filename}}{{route.action}}
+ No routes found +
+
+ +
+ + + +
+
+ + + + + +
+ +
+
+
+ + +
+
+ + + +
+
+
+
+
+
+
+
+ + +
+
+ + + + + + + +
+ + + {{name}} +
+
+
+
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
OccurrencesMost RecentClient IPAppMethodPathError
{{ticket.count}}{{ticket.timestamp}}{{ticket.client_ip}}{{ticket.app_name}}{{ticket.method}}{{ticket.path}}{{ticket.error}}Search
+
+
+
+
+ + +
+ + + + + + + + + + + + + +
ModuleVersion
{{row.name}}{{row.version}}
+
+
+
+ +
+ Created by Massimo Di Pierro @ BSDv3 License +
+
+ + + + + + + + + + diff --git a/apps/_dashboard/static/themes/AlienDark/theme.css b/apps/_dashboard/static/themes/AlienDark/theme.css index e60327a5b..47c2596a1 100644 --- a/apps/_dashboard/static/themes/AlienDark/theme.css +++ b/apps/_dashboard/static/themes/AlienDark/theme.css @@ -167,3 +167,65 @@ select option { background-color: var(--bg-secondary); color: white; } + +/* Files accordion: reduce vertical spacing between folders */ +.my-effects .files li, +.my-effects .dirs li, +.my-effects .accordion .files li, +.my-effects .accordion .dirs li, +.my-effects .accordion li { + margin-bottom: 0 !important; + padding-bottom: 0 !important; + line-height: 1.2 !important; +} + +.my-effects .files ul, +.my-effects .dirs ul { + margin: 0 !important; + padding: 0 !important; +} + +/* Folder rows: reduce accordion spacing inside dirs tree */ +.my-effects .dirs .accordion > * { + margin-bottom: 2px !important; +} + +.my-effects .dirs .accordion > label { + padding: 2px 0 !important; +} + +.my-effects .subfolder { + margin: 0 !important; + padding: 0 !important; +} + +/* Database table buttons: vertically center labels */ +.my-effects .panel.accordion input#databases ~ div button { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1.2 !important; + padding: 4px 10px !important; + height: auto !important; +} + +/* Database table rows: center buttons within row height */ +.my-effects .panel.accordion input#databases ~ div td { + vertical-align: middle !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +/* App buttons: vertically center labels */ +button.btn-app { + display: inline-flex; + align-items: center; + gap: 6px; + height: auto !important; + line-height: 1.2 !important; + padding: 4px 10px !important; +} + +button.btn-app i { + align-self: center; +} diff --git a/apps/_dashboard/static/themes/AlienDark/theme.js b/apps/_dashboard/static/themes/AlienDark/theme.js new file mode 100644 index 000000000..e6fd10463 --- /dev/null +++ b/apps/_dashboard/static/themes/AlienDark/theme.js @@ -0,0 +1,15 @@ +/* AlienDark Theme JavaScript + + This file can contain theme-specific JavaScript code that needs to run + when the AlienDark theme is active. Currently, it's a placeholder for + future functionality. + + To enable theme-specific code: + 1. Add your JavaScript here + 2. The theme-selector.js will automatically load this file when AlienDark + is selected as the active theme +*/ + +(function() { + // Theme initialization code can go here +})(); diff --git a/apps/_dashboard/static/themes/AlienDark/theme.toml b/apps/_dashboard/static/themes/AlienDark/theme.toml new file mode 100644 index 000000000..ec2f48f7b --- /dev/null +++ b/apps/_dashboard/static/themes/AlienDark/theme.toml @@ -0,0 +1,6 @@ +name = "AlienDark" +description = "Sleek dark theme with cyan accents for the py4web dashboard." +version = "1.0.0" +author = "py4web team" +homepage = "https://py4web.com" +screenshot = "widget.gif" diff --git a/apps/_dashboard/static/images/widget.gif b/apps/_dashboard/static/themes/AlienDark/widget.gif similarity index 100% rename from apps/_dashboard/static/images/widget.gif rename to apps/_dashboard/static/themes/AlienDark/widget.gif diff --git a/apps/_dashboard/static/favicon_green.ico b/apps/_dashboard/static/themes/AlienLight/favicon.ico similarity index 100% rename from apps/_dashboard/static/favicon_green.ico rename to apps/_dashboard/static/themes/AlienLight/favicon.ico diff --git a/apps/_dashboard/static/themes/AlienLight/templates/index.html b/apps/_dashboard/static/themes/AlienLight/templates/index.html new file mode 100644 index 000000000..43a561689 --- /dev/null +++ b/apps/_dashboard/static/themes/AlienLight/templates/index.html @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + +
+
+ + +
+
+
+

API error

+

+            
+        
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
{{selected_app.error}}
+
+
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
RuleMethodFilenameActionTime(s)Calls/sErrors/s
{{route.rule}}{{route.method}}{{route.filename}}{{route.action}}
+ No routes found +
+
+ +
+ + + +
+
+ + + + + +
+ +
+
+
+ + +
+
+ + + +
+
+
+
+
+
+
+
+ + +
+
+ + + + + + + +
+ + + {{name}} +
+
+
+
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
OccurrencesMost RecentClient IPAppMethodPathError
{{ticket.count}}{{ticket.timestamp}}{{ticket.client_ip}}{{ticket.app_name}}{{ticket.method}}{{ticket.path}}{{ticket.error}}Search
+
+
+
+
+ + +
+ + + + + + + + + + + + + +
ModuleVersion
{{row.name}}{{row.version}}
+
+
+
+ +
+ Created by Massimo Di Pierro @ BSDv3 License +
+
+ + + + + + + + + + diff --git a/apps/_dashboard/static/themes/AlienLight/theme.css b/apps/_dashboard/static/themes/AlienLight/theme.css index c485077c6..adb3cf7bc 100644 --- a/apps/_dashboard/static/themes/AlienLight/theme.css +++ b/apps/_dashboard/static/themes/AlienLight/theme.css @@ -117,6 +117,68 @@ textarea:focus { background-color: white; } +/* Files accordion: reduce vertical spacing between folders */ +.my-effects .files li, +.my-effects .dirs li, +.my-effects .accordion .files li, +.my-effects .accordion .dirs li, +.my-effects .accordion li { + margin-bottom: 0 !important; + padding-bottom: 0 !important; + line-height: 1.2 !important; +} + +.my-effects .files ul, +.my-effects .dirs ul { + margin: 0 !important; + padding: 0 !important; +} + +/* Folder rows: reduce accordion spacing inside dirs tree */ +.my-effects .dirs .accordion > * { + margin-bottom: 2px !important; +} + +.my-effects .dirs .accordion > label { + padding: 2px 0 !important; +} + +.my-effects .subfolder { + margin: 0 !important; + padding: 0 !important; +} + +/* Database table buttons: vertically center labels */ +.my-effects .panel.accordion input#databases ~ div button { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1.2 !important; + padding: 4px 10px !important; + height: auto !important; +} + +/* Database table rows: center buttons within row height */ +.my-effects .panel.accordion input#databases ~ div td { + vertical-align: middle !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +/* App buttons: vertically center labels */ +button.btn-app { + display: inline-flex; + align-items: center; + gap: 6px; + height: auto !important; + line-height: 1.2 !important; + padding: 4px 10px !important; +} + +button.btn-app i { + align-self: center; +} + /* ============================================================================ 5. CONTENT - Login and Interactive Elements ========================================================================== */ @@ -329,7 +391,3 @@ p, span, div { color: var(--text-primary); } -/* Style favicon icons to green */ -img[src*="favicon.ico"] { - filter: hue-rotate(110deg) saturate(1.5) brightness(0.8); -} diff --git a/apps/_dashboard/static/themes/AlienLight/theme.js b/apps/_dashboard/static/themes/AlienLight/theme.js new file mode 100644 index 000000000..3223e7b15 --- /dev/null +++ b/apps/_dashboard/static/themes/AlienLight/theme.js @@ -0,0 +1,50 @@ +/* AlienLight Theme JavaScript + + This file can contain theme-specific JavaScript code that needs to run + when the AlienLight theme is active. Currently, it's a placeholder for + future functionality. + + To enable theme-specific code: + 1. Add your JavaScript here + 2. The theme-selector.js will automatically load this file when AlienLight + is selected as the active theme +*/ + +(function() { + var ACE_THEME = "ace/theme/chrome"; + + var applyAceTheme = function() { + if (!window.app || !app.vue) { + return false; + } + + if (app.vue.editor && typeof app.vue.editor.setTheme === "function") { + app.vue.editor.setTheme(ACE_THEME); + } + + if (!app.vue.__alienLightAceHooked && typeof app.vue.activate_editor === "function") { + var originalActivate = app.vue.activate_editor.bind(app.vue); + app.vue.activate_editor = function(path, payload) { + originalActivate(path, payload); + if (app.vue.editor && typeof app.vue.editor.setTheme === "function") { + app.vue.editor.setTheme(ACE_THEME); + } + }; + app.vue.__alienLightAceHooked = true; + } + + return true; + }; + + var attempts = 0; + var maxAttempts = 20; + var retry = function() { + attempts += 1; + if (applyAceTheme() || attempts >= maxAttempts) { + return; + } + setTimeout(retry, 150); + }; + + retry(); +})(); diff --git a/apps/_dashboard/static/themes/AlienLight/theme.toml b/apps/_dashboard/static/themes/AlienLight/theme.toml new file mode 100644 index 000000000..76b4a0c3e --- /dev/null +++ b/apps/_dashboard/static/themes/AlienLight/theme.toml @@ -0,0 +1,6 @@ +name = "AlienLight" +description = "Bright light theme with blue accents and high readability." +version = "1.0.0" +author = "py4web team" +homepage = "https://py4web.com" +screenshot = "widget.gif" diff --git a/apps/_dashboard/static/images/widget-transparent.gif b/apps/_dashboard/static/themes/AlienLight/widget.gif similarity index 100% rename from apps/_dashboard/static/images/widget-transparent.gif rename to apps/_dashboard/static/themes/AlienLight/widget.gif diff --git a/apps/_dashboard/templates/dbadmin.html b/apps/_dashboard/templates/dbadmin.html index aef7d9769..6ffd84af8 100644 --- a/apps/_dashboard/templates/dbadmin.html +++ b/apps/_dashboard/templates/dbadmin.html @@ -1,6 +1,68 @@ -[[extend "layout.html"]] + + + + + + + + + + + + + + +
+ +
+
+ + +
+
+
+ + +
+
+
-
-

Application "[[=app_name]]" - Table "[[=table_name]]"

-[[=grid]] -
+ +
+
+

Application "[[=app_name]]" - Table "[[=table_name]]"

+ [[=grid]] +
+
+ + +
+ +
+
+ + + + + diff --git a/apps/_dashboard/templates/index.html b/apps/_dashboard/templates/index.html index 2cd51b0c4..c89403fe5 100644 --- a/apps/_dashboard/templates/index.html +++ b/apps/_dashboard/templates/index.html @@ -1,293 +1,297 @@ - - - - - - - - - - - - - - - -
- -
-
-
-
- - -
-
- - -
-
-
-

API error

-

-            
-        
-
- - -
-
- - -
-
- -
-
-
-
- - -
-
{{selected_app.error}}
-
-
-
- - -
-
- - -
- - - - - - - - - - - - - - - - - - - - - - - -
RuleMethodFilenameActionTime(s)Calls/sErrors/s
{{route.rule}}{{route.method}}{{route.filename}}{{route.action}}
- No routes found -
-
- -
- - - -
-
- - - - - -
- -
-
-
- - -
-
- - - -
-
-
-
-
-
-
-
- - -
-
- - - - - - - -
- - - {{name}} -
-
-
-
-
- - -
-
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
OccurrencesMost RecentClient IPAppMethodPathError
{{ticket.count}}{{ticket.timestamp}}{{ticket.client_ip}}{{ticket.app_name}}{{ticket.method}}{{ticket.path}}{{ticket.error}}Search
-
-
-
-
- - -
- - - - - - - - - - - - - -
ModuleVersion
{{row.name}}{{row.version}}
-
-
-
- -
- Created by Massimo Di Pierro @ BSDv3 License -
-
- - - - - - - - - - + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + +
+
+ + +
+
+
+

API error

+

+            
+        
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
{{selected_app.error}}
+
+
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
RuleMethodFilenameActionTime(s)Calls/sErrors/s
{{route.rule}}{{route.method}}{{route.filename}}{{route.action}}
+ No routes found +
+
+ +
+ + + +
+
+ + + + + +
+ +
+
+
+ + +
+
+ + + +
+
+
+
+
+
+
+
+ + +
+
+ + + + + + + +
+ + + {{name}} +
+
+
+
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
OccurrencesMost RecentClient IPAppMethodPathError
{{ticket.count}}{{ticket.timestamp}}{{ticket.client_ip}}{{ticket.app_name}}{{ticket.method}}{{ticket.path}}{{ticket.error}}Search
+
+
+
+
+ + +
+ + + + + + + + + + + + + +
ModuleVersion
{{row.name}}{{row.version}}
+
+
+
+ +
+ Created by Massimo Di Pierro @ BSDv3 License +
+
+ + + + + + + + + + diff --git a/apps/_dashboard/templates/layout.html b/apps/_dashboard/templates/layout.html index 62e6a9f2c..08e716704 100644 --- a/apps/_dashboard/templates/layout.html +++ b/apps/_dashboard/templates/layout.html @@ -3,9 +3,8 @@ - - - + + [[block page_head]][[end]] @@ -23,7 +22,7 @@
diff --git a/apps/_dashboard/templates/ticket.html b/apps/_dashboard/templates/ticket.html index c8336544d..f81fd210f 100644 --- a/apps/_dashboard/templates/ticket.html +++ b/apps/_dashboard/templates/ticket.html @@ -1,9 +1,8 @@ - - - + +