From c90129285c0139be5baffe293b257f0135706efd Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:32:07 -0400 Subject: [PATCH] Web UI: mobile navigation, guided onboarding, basic/advanced config tiering + performance (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(web): remove dead legacy client-side plugin-config generator (~2,300 lines) Plugin config forms have been rendered server-side (plugin_config.html via GET /partials/plugin-config/) since the HTMX migration; the old client-side generator survived as unreachable code. Verified dead by call graph, not by naming: showPluginConfigModal and showGithubTokenInstructions have zero callers anywhere in templates or JS, and everything removed here is reachable only from those two roots. Removed: - plugins_manager.js: showPluginConfigModal, generatePluginConfigForm, generateFormFromSchema, generateFieldHtml, generateSimpleConfigForm, handlePluginConfigSubmit, the modal's JSON-editor view (initJsonEditor, switchPluginConfigView, syncFormToJson/JsonToForm, saveConfigFromJsonEditor, resetPluginConfigToDefaults, displayValidationErrors, closePluginConfigModal, savePluginConfiguration, currentPluginConfigState), their exclusive helpers (getSchemaPropertyType, escapeCssSelector, dotToNested, collectBooleanFields, normalizeFormDataForConfig, flattenConfig, loadCustomHtmlWidget), the orphaned-modal cleanup block, the modal's listener wiring, and the never-invoked showGithubTokenInstructions/closeInstructionsModal pair. - plugins.html: the #plugin-config-modal markup those functions drove. - base.html: the deprecated pluginConfigData() component and the window.PluginConfigHelpers shim (only ever called by pluginConfigData). Deliberately kept, verified still live: - renderArrayObjectItem, getSchemaProperty, escapeHtml/escapeAttribute (window-exposed for the top-level array-of-objects handlers the server-rendered form uses), toggleNestedSection, addKeyValuePair/ addArrayObjectItem families, executePluginAction, and window.currentPluginConfig = null init (file-upload.js and executePluginAction read it, optional-chained). - app()'s internal generateConfigForm/generateSimpleConfigForm methods in base.html: unreachable now but embedded in the live Alpine component; excising methods from a live object is deferred to keep this change zero-risk. Validation: every deletion seam inspected line-by-line; Jinja parse of both templates passes; repo-wide sweep confirms zero remaining references to any deleted function or element id (deleted ranges contained no Jinja tags). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): mobile navigation drawer + responsive CSS gap fixes Phones previously got the desktop layout squeezed: ~12 system tabs plus one tab per installed plugin wrapped into many rows of small pill buttons, and the header's settings-search and system-stats widgets were dropped entirely (hidden below their breakpoints, never relocated). - Off-canvas nav drawer below md: the existing nav markup (system tab row + #plugin-tabs-row, including dynamically injected plugin tabs) is wrapped in a #site-nav container that CSS repositions into a slide-in drawer on small screens. Same DOM nodes, same @click handlers, nothing duplicated. Tabs become full-width rows with 44px+ touch targets. A hamburger button (md:hidden) in the header and a backdrop toggle the new mobileNavOpen Alpine state (added to both app() definitions, mirroring activeTab). Clicking any tab, a search result, or the backdrop closes the drawer. At md+ hard CSS guards make all drawer styles inert - desktop renders exactly as before. - Header widgets relocated, not hidden: placeHeaderWidgets() in app.js moves the #settings-search-wrap and #system-stats nodes (same elements, listeners intact - both are looked up by id from SSE/search code, so they must never be duplicated) into the drawer below md and back into the header above it, via a matchMedia listener. - Fixed 13 breakpoint utility classes that templates referenced but app.css never defined (sm:block, sm:grid-cols-2, sm:text-sm, md:block, md:w-auto, lg:block, lg:flex, lg:w-64, xl:grid-cols-2/3, 2xl:grid-cols-2/3/4). This was a live bug: 'hidden sm:block' on the search box and 'hidden lg:flex' on the stats meant BOTH were invisible at every screen width. Audit method (repeatable): diff classes used in templates vs defined in app.css. - Mobile modal sizing: one global rule caps .modal-content at 95vw/90vh with internal scroll below 640px - covers every modal without per-template changes. - Horizontal-scroll affordance: pure-CSS edge-fade shadows on .overflow-x-auto containers (scrolling-shadows technique), plus larger in-table touch targets below md. Validation: breakpoint used-vs-defined audit now returns zero gaps; Jinja parse of base.html passes; all changes to desktop behavior are additive (new utilities) or scoped inside max-width media queries. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): x-advanced schema flag groups plugin config fields under a collapsed Advanced Settings section Plugin config pages show every schema property at equal visual priority, which overwhelms first-time users. Plugin authors can now add "x-advanced": true to any flat (non-object) property in config_schema.json to move it into one collapsed "Advanced Settings (N)" section rendered after the basic fields - progressive disclosure with zero loss of control. Implementation: the main render loop in plugin_config.html splits ordered properties into basic/advanced tiers; the advanced group reuses the exact .nested-section/.nested-content/toggleSection() shell that nested object sections already use, so the settings search's expand-on-match behavior works on advanced fields with no JS changes. Object-type properties ignore the flag (they already render as their own collapsible sections). No backend change needed: jsonschema ignores unknown x-* keywords exactly as it does for x-widget/x-propertyOrder. Documented in docs/widget-guide.md alongside the other x-* extensions. Validation (rendered with real Jinja, not just parsed): - synthetic schema with 2 advanced fields: basic fields render before the section, advanced inside the collapsed shell, count badge correct, x-advanced on an object property correctly ignored - schema without any x-advanced: output is identical to the pre-change template (whitespace-normalized diff against git HEAD's version) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): Display Settings basic/advanced split + live total-resolution readout The Hardware Configuration card showed ~17 fields at equal priority; a new user only needs 7 of them to get a correctly-sized, correctly-colored image (rows, cols, chain_length, parallel, brightness, hardware_mapping, led_rgb_sequence). The other 10 (multiplexing, panel_type, row_address_type, gpio_slowdown, rp1_rio, scan_mode, pwm_bits, pwm_dither_bits, pwm_lsb_nanoseconds, limit_refresh_rate_hz) now live in a collapsed "Advanced Hardware Settings" section using the same nested-section shell as plugin config forms, so toggleSection() and settings-search auto-expand work unchanged. led_rgb_sequence moved up beside brightness/hardware_mapping (2-col grid became 3-col). No field was removed or renamed; the form still posts the same names to /api/v3/config/main. Also adds a live "Your display: W x H pixels" readout under the four sizing fields (width = cols x chain_length, height = rows x parallel - the exact math the chain-length tooltip describes in prose), recomputed client-side on every input event, no round-trip. Deviation from plan, deliberate: disable_hardware_pulsing / inverse_colors / show_refresh_rate stay in their separate "Display Options" card rather than moving across cards - relocating fields between form sections risks regressions for no decluttering gain in the card users complained about. Validation (real Jinja render): all 17 hardware fields present exactly once, basic fields render before the advanced section and the 10 advanced fields inside it, div count balanced (71/71), readout + recompute script present. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): plugin install auto-enables + persistent restart nudge Getting a plugin onto the display used to take three disconnected manual steps: install from the store, flip its enable toggle, then restart the display service - with no in-UI hint that steps 2 and 3 were needed (only docs/GETTING_STARTED.md mentions it). - installPlugin() now enables the plugin immediately on successful install (owner-confirmed behavior change: always auto-enable, no opt-out; users who don't want it running toggle it off as before), then shows a persistent toast ("... restart the display to show it") with an inline "Restart Now" button wired to the existing restartDisplay() - the same function the three existing Restart Display buttons call. - notification.js: show() accepts optional { actionLabel, onAction } to render one inline action button per toast. Callbacks are stored per notification id and cleaned up on dismiss; a new triggerAction() public method runs the callback and dismisses. The global showNotification() shorthand now forwards a full options object as its second argument (legacy type-string calls unchanged). Scope note: applies to the plugin store's install path (window.installPlugin). The custom-registry install path keeps its existing behavior. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): dismissible Getting Started checklist on Overview New users land on a dense multi-tab dashboard with no suggested order of operations (the only guided flow is the WiFi captive portal). This adds a non-gating checklist card at the top of Overview with five steps, each a deep link that switches to the right tab (and closes the mobile nav drawer): 1. Set panel size -> Display tab (done: rows/cols/chain_length > 0) 2. Set timezone/location -> General tab (done: differs from template defaults America/New_York / Tampa) 3. Install a plugin -> Plugins tab (done: /api/v3/plugins/installed non-empty) 4. Enable a plugin -> Plugins tab (done: any installed plugin enabled) 5. Configure it -> Plugins tab (done: first enabled plugin has >=1 saved value differing from its schema defaults) Steps 1-2 are computed server-side in Jinja from main_config (already in the partial's context); 3-5 client-side from existing endpoints. No new backend state: dismissal persists in localStorage (mirroring the reconciliation banner's sessionStorage pattern one section up); deep links use the same _x_dataStack app-data access as settings-search.js. Disclosed heuristic limit: values left at legitimate defaults (a user actually in Tampa) read as "not done". Validation: real Jinja render across 3 config variants confirms the server-side done-flags flip correctly; div balance intact; /plugins/config response shape (config dict directly in .data) verified against api_v3.py. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(display): drag-and-drop plugin rotation order for the primary display mode The primary rotation's order was invisible and unconfigurable: modes are registered in parallel-load COMPLETION order, so rotation order actually varied between restarts. Only the niche Vegas Scroll mode had a working order UI. This adds real, persisted ordering end to end: Backend: - config.template.json: new display.plugin_rotation_order (default [], fully backward compatible). - display_controller.py: _apply_plugin_rotation_order() rebuilds available_modes grouped by plugin per the configured list (each plugin's modes keep their declared order; unlisted plugins follow in existing relative order; empty config = exact no-op). Applied at startup after parallel load and after live enable/disable reconcile (before the existing _resync_mode_index_after_change, which preserves the current mode). Mirrors vegas_mode get_ordered_plugins() semantics. - api_v3.py save_main_config: accepts plugin_rotation_order as a JSON array (same parse/guard pattern as vegas_plugin_order). Frontend: - New shared widget static/v3/js/widgets/plugin-order-list.js: the Vegas section's drag-and-drop list factored out verbatim (native HTML5 drag events, saved-order-first rendering, hidden-input JSON sync), parameterized by container/order-input/optional exclude-checkbox/badge. - display.html: Vegas section now calls the shared module; its ~130-line inline copy of the same logic is deleted. - durations.html: new "Rotation Order" card above the durations grid using the same module, posting plugin_rotation_order with the existing form. Deviation from plan, deliberate: durations stay as their own mode-keyed grid rather than inline in the drag rows - verified display_durations keys are MODE names (display_controller.py resolves duration per mode_key), not plugin ids, and one plugin can own several modes, so the planned 1:1 inline pairing was wrong. Validation: py_compile on both Python files; _apply_plugin_rotation_order unit-tested standalone (configured order applied, empty-config no-op, unknown ids skipped - 3/3); both templates render with balanced divs, the hidden input carries the saved order, and the old inline implementation is confirmed gone; config.template.json parses. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * feat(web): serve the interface at / — /v3 kept as a legacy alias The user-visible URL no longer carries the interface version: the pages blueprint is now registered un-prefixed (primary) AND at /v3 (second registration, name='pages_v3_legacy'), so: - http:/// serves the interface directly (the old @app.route('/') redirect is removed — the blueprint's own index takes its place) - every existing /v3/... bookmark and all the hardcoded /v3/partials/... fetches in templates/JS keep working verbatim through the alias mount — zero template/JS churn, zero broken links - url_for('pages_v3.*') resolves against the primary registration, so all server-side redirects (captive portal detection endpoints) now emit un-prefixed URLs - the AP-mode captive-portal allowlist learned the un-prefixed page paths (/setup, /partials/, /settings/, /plugin-ui/) so setup-mode requests don't redirect-loop - /api/v3 and the templates/v3, static/v3 directories are deliberately untouched (internal, invisible to users; owner-confirmed scope) Validation: dual registration mechanics tested against real Flask (test client): /, /v3, /v3/ redirect, partials and /setup reachable on both mounts, url_for yields un-prefixed paths; py_compile passes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * perf(web): stream the preview PNG raw instead of PIL decode + re-encode display_preview_generator() opened each changed snapshot with PIL and re-encoded it to PNG just to base64 it — but /tmp/led_matrix_preview.png already IS a PNG, written atomically by the display service (tmp file + os.replace in display_manager.py), so a partially-written file can never be observed. Read the bytes and base64 them directly: identical payload (front-end consumes data:image/png;base64 — verified in base.html), one full image decode+encode per frame less on the same Pi that's driving the matrix. The existing mtime skip and viewer-marker throttling are unchanged (they already covered the "skip unchanged frames" concern). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * perf(web): gzip response compression via flask-compress The interface ships a ~5,000-line HTML shell and >20k lines of JS uncompressed; on phone/WiFi that dominates load time. Flask-Compress gzips/brotlis compressible responses transparently. - Optional dependency, same graceful pattern as flask-limiter: missing package = uncompressed responses, no crash. - SSE safety verified empirically against the real package (1.24): an actual streamed text/event-stream response comes back with no Content-Encoding while a large HTML response gzips — the display preview / stats / logs streams are unaffected. - Added flask-compress>=1.14 to web_interface/requirements.txt. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * perf(web): gate verbose console logging behind the existing pluginDebug switch plugins_manager.js, base.html's inline scripts, and app.js emitted 198 console.log calls in production - including per-interaction [DEBUG] dumps - costing main-thread time and drowning real errors in noise. - New window.debugLog() gate defined in base.html's first inline script (before any other script runs): forwards to console.log only when localStorage.pluginDebug === 'true' - the SAME switch plugins_manager.js already used for its _PLUGIN_DEBUG_EARLY logs, so existing debug workflow docs stay valid. Exposed as window.LEDMATRIX_DEBUG for other scripts. - Mechanically rewrote console.log( -> debugLog( in plugins_manager.js (127), base.html (64), app.js (7). Verified no occurrences lived inside string literals before rewriting; console.error/console.warn untouched. - app.js's no-Alpine showNotification fallback restored to console.info - it's a user-facing last resort, not debug output. Both load paths are safe: the gate is the first inline - - - - - - - - + - + - + - - - - + + + + - - + + @@ -874,14 +296,25 @@
+ +

- LED Matrix Control - v3 + LED Matrix Control

-
+
+ +
+
+
+
+
+
+
+
+
+
+
@@ -1429,3340 +952,7 @@
- + @@ -4791,6 +981,7 @@ + @@ -4822,269 +1013,9 @@ - - + +
@@ -454,6 +470,48 @@
+ +
+

Double-Sided Display

+

Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.

+ +
+
+ +
+ +
+ + +
+ +
+ + +
+
+
+ + +
@@ -583,183 +641,32 @@ if (typeof window.fixInvalidNumberInputs !== 'function') { }); } - // Initialize plugin order list - function initPluginOrderList() { + // Initialize plugin order list via the shared drag-and-drop module + // (static/v3/js/widgets/plugin-order-list.js) — the same component the + // Durations tab uses for the primary rotation order. + function initPluginOrderList(attempt) { const container = document.getElementById('vegas_plugin_order'); if (!container) return; - - // Fetch available plugins - fetch('/api/v3/plugins/installed') - .then(response => response.json()) - .then(data => { - // Handle both {data: {plugins: []}} and {plugins: []} response formats - const allPlugins = data.data?.plugins || data.plugins || []; - if (!allPlugins || allPlugins.length === 0) { - container.innerHTML = '

No plugins available

'; - return; - } - - // Get current order and exclusions - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - let currentOrder = []; - let excluded = []; - - try { - currentOrder = JSON.parse(orderInput.value || '[]'); - excluded = JSON.parse(excludedInput.value || '[]'); - } catch (e) { - console.error('Error parsing vegas config:', e); - } - - // Build ordered plugin list (only enabled plugins) - const plugins = allPlugins.filter(p => p.enabled); - const orderedPlugins = []; - - // First add plugins in current order - currentOrder.forEach(id => { - const plugin = plugins.find(p => p.id === id); - if (plugin) orderedPlugins.push(plugin); - }); - - // Then add remaining plugins - plugins.forEach(plugin => { - if (!orderedPlugins.find(p => p.id === plugin.id)) { - orderedPlugins.push(plugin); - } - }); - - // Build HTML with display mode indicators - let html = ''; - orderedPlugins.forEach((plugin, index) => { - const isExcluded = excluded.includes(plugin.id); - // Determine display mode (from plugin config or default) - const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed'; - const modeLabels = { - 'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }, - 'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }, - 'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' } - }; - const modeInfo = modeLabels[vegasMode] || modeLabels['fixed']; - // Escape plugin metadata to prevent XSS - const safePluginId = escapeAttr(plugin.id); - const safePluginName = escapeHtml(plugin.name || plugin.id); - html += ` -
- - - - ${modeInfo.label} - -
- `; - }); - - container.innerHTML = html || '

No enabled plugins

'; - - // Setup drag and drop - setupDragAndDrop(container); - - // Setup checkbox handlers - container.querySelectorAll('.vegas-plugin-include').forEach(checkbox => { - checkbox.addEventListener('change', updatePluginConfig); - }); - - // Initialize hidden inputs with current state - updatePluginConfig(); - }) - .catch(error => { - console.error('Error fetching plugins:', error); - container.innerHTML = '

Error loading plugins

'; - }); - } - - function setupDragAndDrop(container) { - let draggedItem = null; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - item.addEventListener('dragstart', function(e) { - draggedItem = this; - this.style.opacity = '0.5'; - e.dataTransfer.effectAllowed = 'move'; - }); - - item.addEventListener('dragend', function() { - this.style.opacity = '1'; - draggedItem = null; - updatePluginConfig(); - }); - - item.addEventListener('dragover', function(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - this.style.borderTop = '2px solid #3b82f6'; - this.style.borderBottom = ''; - } else { - this.style.borderBottom = '2px solid #3b82f6'; - this.style.borderTop = ''; - } - }); - - item.addEventListener('dragleave', function() { - this.style.borderTop = ''; - this.style.borderBottom = ''; - }); - - item.addEventListener('drop', function(e) { - e.preventDefault(); - this.style.borderTop = ''; - this.style.borderBottom = ''; - - if (draggedItem && draggedItem !== this) { - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - container.insertBefore(draggedItem, this); - } else { - container.insertBefore(draggedItem, this.nextSibling); - } - } - }); - }); - } - - function updatePluginConfig() { - const container = document.getElementById('vegas_plugin_order'); - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - - if (!container || !orderInput || !excludedInput) return; - - const order = []; - const excluded = []; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - const pluginId = item.dataset.pluginId; - const checkbox = item.querySelector('.vegas-plugin-include'); - - order.push(pluginId); - if (checkbox && !checkbox.checked) { - excluded.push(pluginId); + if (!window.PluginOrderList) { + // Widget script is deferred; retry briefly, then surface a real + // error instead of waiting forever. + if ((attempt || 0) < 50) { + setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100); + } else { + container.textContent = 'Could not load the reorder widget — reload the page to try again.'; + container.className = 'text-sm text-red-500'; } + return; + } + window.PluginOrderList.init({ + containerId: 'vegas_plugin_order', + orderInputId: 'vegas_plugin_order_value', + excludedInputId: 'vegas_excluded_plugins_value', + showVegasModeBadge: true }); - - orderInput.value = JSON.stringify(order); - excludedInput.value = JSON.stringify(excluded); } + // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPluginOrderList); diff --git a/web_interface/templates/v3/partials/durations.html b/web_interface/templates/v3/partials/durations.html index 0a1f05aa..e05f8aa1 100644 --- a/web_interface/templates/v3/partials/durations.html +++ b/web_interface/templates/v3/partials/durations.html @@ -1,8 +1,8 @@ {% import 'v3/partials/_macros.html' as ui %}
-

Display Durations

-

Configure how long each screen is shown before switching. Values in seconds.

+

Rotation & Durations

+

Set the order plugins rotate on the display and how long each screen is shown. Durations are in seconds.

{{ ui.settings_filter() }} @@ -16,22 +16,53 @@ novalidate onsubmit="fixInvalidNumberInputs(this); return true;"> -
- {% for key, value in main_config.display.display_durations.items() %} -
- - + +
+

Rotation Order

+

Drag plugins to set the order they rotate on the display. Each plugin's screens keep their own order within its turn. Takes effect after saving and restarting the display.

+
+

Loading plugins…

+
+ +
+ + {% if duration_groups %} +
+
+

Screen Durations

+

How long each screen stays on before rotating to the next one, in seconds (5–600, default 30).

+
+ {% for group in duration_groups %} +
+

{{ group.plugin_name }}

+
+ {% for mode in group.modes %} +
+ + +
+ {% endfor %} +
{% endfor %}
+ {% else %} +
+

No enabled plugins found — enable a plugin in the Plugin Manager to set its screen durations here.

+
+ {% endif %}
@@ -43,3 +74,34 @@
+ + diff --git a/web_interface/templates/v3/partials/overview.html b/web_interface/templates/v3/partials/overview.html index cc74120a..47713d8d 100644 --- a/web_interface/templates/v3/partials/overview.html +++ b/web_interface/templates/v3/partials/overview.html @@ -61,6 +61,149 @@ }()); + +{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %} +{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %} +{% set _loc = main_config.location if main_config and main_config.location else {} %} +{% set _loc_done = (main_config.timezone and main_config.timezone != 'America/New_York') + or (_loc.city and _loc.city != 'Tampa') %} + + +

System Overview

@@ -221,7 +364,10 @@

Live Display Preview

-
+ +
+
+ {% endif %} {% else %} {# No schema - render simple form from config #} {% if config %} diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index cf6aa726..ba7f0c88 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -466,65 +466,6 @@
- - -
-