mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 10:58:15 +00:00
Merge main into feat/plugin-composer, and fix three review findings
The branch was 57 commits behind and conflicting. I had put the rebase
aside earlier as needing the author's eyes, on the grounds that the PR is
+5091 lines -- but that was the wrong measure. The actual conflict was a
single hunk in app.css, where this branch adds .md\:inline and main added
.md\:block and .md\:w-auto at the same place. All three are kept.
Merging rather than rebasing: the branch is public and 57 commits behind,
so a rebase would rewrite shared history for a force-push.
Three findings fixed on top:
A missing `text` or `format` was a 500. `p` is a copy of the raw element
and the defaults were applied to the locals t1/fmt1 only, so an element
omitting either key left it absent, manager.py.j2 rendered
`{{ el.text | tojson }}` over a jinja2.Undefined, and tojson raised
TypeError -- which no handler catches:
text without 'text': TypeError: Object of type Undefined is not
JSON serializable
clock without 'format': same
Both keys are now set explicitly. Verified: removing either assignment
fails 4 of the new tests.
E741 on my own injection-test file: two `for i, l in enumerate(...)`
loops, which ruff rejects and would fail a lint-gated build. Renamed.
Ruff now clean on all three files this PR touches.
Not done: registering composer_bp. This PR's own description gates it --
"Not yet wired up ... tracking as a follow-up", with an unchecked box for
"Register composer_bp in app.py before merging or exposing this route" --
so it is a deliberate decision, not an oversight. Confirmed the blueprint
appears in no register_blueprint call outside this branch's tests, which
also means the code-injection fixed earlier in this PR was never
reachable in a deployed instance. Worth fixing before the route is
exposed; not worth exposing the route to satisfy a review comment.
Verified on the merged tree: 3850 passed, 1 failed, 60 skipped. The
failure is test_install_lowmem's tmpfs assumption, which is fixed in #492
and not yet on main. The static audit now passes 3/3 -- the twelve
classes it flagged before were defined on main all along and only looked
missing because this branch was behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
This commit is contained in:
+219
-4258
File diff suppressed because it is too large
Load Diff
@@ -87,7 +87,7 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="/v3">Open Full Interface</a>
|
||||
<a href="/">Open Full Interface</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
|
||||
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>)
|
||||
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
|
||||
Check the <a href="/v3/logs" class="underline font-medium">Logs tab</a> for the full error.
|
||||
Check the <a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> for the full error.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
hx-ext="json-enc"
|
||||
hx-headers='{"Content-Type": "application/json"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Display settings saved', event.detail.xhr.responseJSON?.status || 'success')"
|
||||
hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)"
|
||||
class="space-y-6"
|
||||
novalidate
|
||||
onsubmit="fixInvalidNumberInputs(this); return true;">
|
||||
@@ -47,7 +47,7 @@
|
||||
name="rows"
|
||||
value="{{ main_config.display.hardware.rows or 32 }}"
|
||||
min="1"
|
||||
max="64"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Live total-resolution readout: width = cols x chain_length, height = rows x parallel -->
|
||||
<p id="display-resolution-readout" class="text-sm text-gray-600 mb-4" aria-live="polite">
|
||||
<i class="fas fa-expand-arrows-alt mr-1 text-gray-400"></i>
|
||||
Your display: <strong id="display-resolution-value">—</strong>
|
||||
<span class="text-gray-400">(columns × chain length wide, rows × parallel tall)</span>
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group" id="setting-display-brightness" data-setting-key="display.hardware.brightness">
|
||||
<label for="brightness" class="block text-sm font-medium text-gray-700">Brightness{{ ui.help_tip('Overall LED brightness (1–100%).\nLower is dimmer, higher is brighter. Recommended: 70–90 indoors, 90–100 in bright rooms.', 'Brightness') }}</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -109,9 +116,15 @@
|
||||
<option value="regular-pi1" {% if main_config.display.hardware.hardware_mapping == "regular-pi1" %}selected{% endif %}>Regular Pi1</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group" id="setting-display-orientation" data-setting-key="display.hardware.orientation">
|
||||
<label for="orientation" class="block text-sm font-medium text-gray-700">Panel Orientation{{ ui.help_tip('Rotates the rendered image to match how the panel is physically mounted.\nUse "Upside Down" if you flipped the panel 180° to move the Raspberry Pi / wiring to a more convenient side.', 'Panel Orientation') }}</label>
|
||||
<select id="orientation" name="orientation" class="form-control">
|
||||
<option value="normal" {% if main_config.display.hardware.get('orientation', 'normal') == "normal" %}selected{% endif %}>Normal</option>
|
||||
<option value="180" {% if main_config.display.hardware.get('orientation', 'normal') == "180" %}selected{% endif %}>Upside Down (180°)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
|
||||
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
|
||||
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
|
||||
@@ -123,7 +136,29 @@
|
||||
<option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced hardware settings: niche-panel and deep tuning fields.
|
||||
Collapsed by default; reuses the same nested-section shell as
|
||||
plugin config forms, so toggleSection() and the settings
|
||||
search's auto-expand both work unchanged. -->
|
||||
<div class="nested-section border border-gray-300 rounded-lg mt-4">
|
||||
<button type="button"
|
||||
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
|
||||
aria-controls="display-section-advanced-hardware"
|
||||
aria-expanded="false"
|
||||
onclick="toggleSection('display-section-advanced-hardware')">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900">
|
||||
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware & Display Options (15)
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 mt-1">Multiplexing, panel variants, PWM tuning, and display options — the defaults work for standard HUB75 panels.</p>
|
||||
</div>
|
||||
<i id="display-section-advanced-hardware-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
||||
</button>
|
||||
<div id="display-section-advanced-hardware" class="nested-content bg-gray-50 px-4 py-4 space-y-4 hidden" style="display: none;">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group" id="setting-display-multiplexing" data-setting-key="display.hardware.multiplexing">
|
||||
<label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing{{ ui.help_tip('Pixel-mapping scheme used by outdoor/specialty panels.\nLeave at 0 (Direct) for most indoor panels. Only change if the image is scrambled — try values until it looks right.', 'Multiplexing') }}</label>
|
||||
<select id="multiplexing" name="multiplexing" class="form-control">
|
||||
@@ -255,47 +290,6 @@
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Double-Sided Display -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">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.</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox"
|
||||
id="double_sided_enabled"
|
||||
name="double_sided_enabled"
|
||||
value="true"
|
||||
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
|
||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
|
||||
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (2–8).\nMust divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
|
||||
<input type="number"
|
||||
id="double_sided_copies"
|
||||
name="double_sided_copies"
|
||||
value="{{ main_config.display.get('double_sided', {}).get('copies', 2) }}"
|
||||
min="2"
|
||||
max="8"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
|
||||
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
|
||||
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
|
||||
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal — chained panels (side by side)</option>
|
||||
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — parallel chains (stacked)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Options -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
@@ -368,6 +362,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /#display-section-advanced-hardware (nested-content) -->
|
||||
</div> <!-- /advanced hardware nested-section -->
|
||||
|
||||
<script>
|
||||
// Live "Your display: W x H" readout - width = cols x chain_length,
|
||||
// height = rows x parallel (same math as the chain-length tooltip).
|
||||
(function () {
|
||||
const ids = ['rows', 'cols', 'chain_length', 'parallel'];
|
||||
const out = document.getElementById('display-resolution-value');
|
||||
if (!out) return;
|
||||
function recompute() {
|
||||
const v = {};
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id);
|
||||
v[id] = el ? parseInt(el.value, 10) : NaN;
|
||||
}
|
||||
if (Object.values(v).some(n => !Number.isFinite(n) || n <= 0)) {
|
||||
out.textContent = '—';
|
||||
return;
|
||||
}
|
||||
out.textContent = (v.cols * v.chain_length) + ' × ' + (v.rows * v.parallel) + ' pixels';
|
||||
}
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', recompute);
|
||||
}
|
||||
recompute();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Vegas Scroll Mode Settings -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mt-6">
|
||||
@@ -409,7 +433,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
|
||||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label>
|
||||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap where one plugin hands off to the next (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer. This does not apply between rows of the same plugin — see Row Gap for that.', 'Separator Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_separator_width"
|
||||
name="vegas_separator_width"
|
||||
@@ -420,6 +444,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
|
||||
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Extra gap always added between rows contributed by the same plugin (0–128 px).\nDefault: 8. This is a floor on top of Row Separation below, which does most of the work. Set both to 0 to butt rows directly together.', 'Row Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_intra_plugin_gap"
|
||||
name="vegas_intra_plugin_gap"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('intra_plugin_gap', 8) }}"
|
||||
min="0"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_min_content_separation" data-setting-key="display.vegas_scroll.min_content_separation">
|
||||
<label for="vegas_min_content_separation" class="block text-sm font-medium text-gray-700">Row Separation (pixels){{ ui.help_tip('Blank space guaranteed between rows of the same plugin, measured from the actual content rather than added blindly (0–256 px).\nDefault: 24. Rows already carrying wide margins get nothing added; rows drawn right up to their own edges — sports score cards, for instance — get the full amount, so they no longer look like they are touching. Raise it if items still feel cramped.', 'Row Separation') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_content_separation"
|
||||
name="vegas_min_content_separation"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_content_separation', 24) }}"
|
||||
min="0"
|
||||
max="256"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_render_width_pct" data-setting-key="display.vegas_scroll.render_width_pct">
|
||||
<label for="vegas_render_width_pct" class="block text-sm font-medium text-gray-700">Plugin Render Width (%){{ ui.help_tip('How much of the screen width each plugin is told it has while drawing for the ticker (10–100%).\nDefault: 100 (unchanged). Lowering it makes plugins choose a tighter layout rather than being cropped — a weather forecast becomes narrow cards instead of five columns spread across the panel. Useful on wide displays. Override per plugin with the vegas_width_pct setting in that plugin\'s own configuration.', 'Plugin Render Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_render_width_pct"
|
||||
name="vegas_render_width_pct"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('render_width_pct', 100) }}"
|
||||
min="10"
|
||||
max="100"
|
||||
step="5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
|
||||
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
|
||||
@@ -440,6 +502,194 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cycle Pacing -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Cycle Pacing</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">How long one pass through the ticker lasts, and how many plugins it covers.</p>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_continuous_scroll" data-setting-key="display.vegas_scroll.continuous_scroll">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_continuous_scroll"
|
||||
name="vegas_continuous_scroll"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('continuous_scroll', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Scroll continuously between groups{{ ui.help_tip('Keep one endless strip, extending it with the next group of plugins as the scroll approaches the end, so they simply arrive from the right. Default: on.\nWith this off the ticker builds a fresh strip and swaps it in, which stops the motion, replaces everything at once and restarts with the screen already full — a freeze, a flash and a jump.', 'Continuous Scroll') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_smooth_scroll" data-setting-key="display.vegas_scroll.smooth_scroll">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_smooth_scroll"
|
||||
name="vegas_smooth_scroll"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Smooth sub-pixel motion{{ ui.help_tip('Blend between neighbouring pixel positions so the ticker moves once per rendered frame instead of once per pixel. Default: on.\nWithout it, motion happens only as often as the scroll speed in pixels per second — at 50 px/s that is 50 steps a second however fast the display renders, which reads as a slight judder. The trade is that text softens very slightly horizontally, since each frame blends two positions. Turn it off if you prefer maximum crispness.', 'Smooth Scrolling') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
|
||||
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.0–10.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_extend_threshold_screens"
|
||||
name="vegas_extend_threshold_screens"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('extend_threshold_screens', 2.0) }}"
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_plugins_per_cycle" data-setting-key="display.vegas_scroll.plugins_per_cycle">
|
||||
<label for="vegas_plugins_per_cycle" class="block text-sm font-medium text-gray-700">Plugins Per Cycle{{ ui.help_tip('How many plugins are composed into one pass of the ticker (1–50).\nDefault: 6. Higher means more variety before the ticker restarts, and fewer recompose pauses. Lower means each plugin comes around sooner.', 'Plugins Per Cycle') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_plugins_per_cycle"
|
||||
name="vegas_plugins_per_cycle"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('plugins_per_cycle', 6) }}"
|
||||
min="1"
|
||||
max="50"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_overflow_mode" data-setting-key="display.vegas_scroll.overflow_mode">
|
||||
<label for="vegas_overflow_mode" class="block text-sm font-medium text-gray-700">When A Plugin Is Too Wide{{ ui.help_tip('What to do when a plugin has more content than its width allowance.\nRotate through it: show a different slice each time round, so everything is seen eventually. Right for interchangeable items like news headlines, odds or stock prices.\nShow the start only: always display from the beginning and drop the rest. Right for ordered content — a league table that shows ranks 1-6 and then resumes at 7 two rotations later reads as out of order.\nDefault: rotate. Override for one plugin with vegas_overflow in its own settings.', 'Overflow Handling') }}</label>
|
||||
<select id="vegas_overflow_mode" name="vegas_overflow_mode" class="form-control">
|
||||
<option value="rotate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'rotate' %}selected{% endif %}>Rotate through it (default)</option>
|
||||
<option value="truncate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'truncate' %}selected{% endif %}>Show the start only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
|
||||
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (0–20).\nDefault: 0 (no limit) — every plugin shows all of its content and always starts at the beginning.\nSet a limit to stop one long ticker holding the display for minutes: it is cut to this width and the remainder shown on later cycles. The trade-off is that such a plugin then resumes mid-content on each appearance instead of starting fresh.', 'Max Plugin Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_max_plugin_width_ratio"
|
||||
name="vegas_max_plugin_width_ratio"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 0.0) }}"
|
||||
min="0"
|
||||
max="20"
|
||||
step="0.5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4" id="setting-display-vegas_dynamic_duration_enabled" data-setting-key="display.vegas_scroll.dynamic_duration_enabled">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_dynamic_duration_enabled"
|
||||
name="vegas_dynamic_duration_enabled"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('dynamic_duration_enabled', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Size cycle time to the content{{ ui.help_tip('When on, each cycle runs just long enough to scroll all its content past, clamped to the min and max below.\nWhen off, the max is always used. Default: on.', 'Dynamic Cycle Duration') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||
<div class="form-group" id="setting-display-vegas_min_cycle_duration" data-setting-key="display.vegas_scroll.min_cycle_duration">
|
||||
<label for="vegas_min_cycle_duration" class="block text-sm font-medium text-gray-700">Min Cycle Time (seconds){{ ui.help_tip('Shortest a single ticker pass may last (5–3600 s).\nDefault: 60.', 'Min Cycle Time') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_cycle_duration"
|
||||
name="vegas_min_cycle_duration"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cycle_duration', 60) }}"
|
||||
min="5"
|
||||
max="3600"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_max_cycle_duration" data-setting-key="display.vegas_scroll.max_cycle_duration">
|
||||
<label for="vegas_max_cycle_duration" class="block text-sm font-medium text-gray-700">Max Cycle Time (seconds){{ ui.help_tip('Longest a single ticker pass may last before it restarts with fresh content (10–3600 s).\nThis is the setting that caps total Vegas scroll time. Default: 240. Lower it if the ticker feels like it takes too long to come back around.', 'Max Cycle Time') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_max_cycle_duration"
|
||||
name="vegas_max_cycle_duration"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_cycle_duration', 240) }}"
|
||||
min="10"
|
||||
max="3600"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dead Space -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Dead Space</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">Plugins that draw onto a full-screen canvas contribute all the empty space around their content. Trimming reclaims it so the ticker stays full.</p>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_auto_trim" data-setting-key="display.vegas_scroll.auto_trim">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_auto_trim"
|
||||
name="vegas_auto_trim"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('auto_trim', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Trim empty edges from plugin content{{ ui.help_tip('Crops blank columns from the left and right of each plugin block before it enters the ticker. Space between two pieces of content inside a block is left alone, so layouts are not altered. Default: on.', 'Auto Trim') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_content_padding" data-setting-key="display.vegas_scroll.content_padding">
|
||||
<label for="vegas_content_padding" class="block text-sm font-medium text-gray-700">Content Padding (pixels){{ ui.help_tip('Blank columns kept either side of trimmed content, so it does not butt against the separator (0–128 px).\nDefault: 8.', 'Content Padding') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_content_padding"
|
||||
name="vegas_content_padding"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('content_padding', 8) }}"
|
||||
min="0"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_lead_in_width" data-setting-key="display.vegas_scroll.lead_in_width">
|
||||
<label for="vegas_lead_in_width" class="block text-sm font-medium text-gray-700">Lead-In Gap (pixels){{ ui.help_tip('Blank space before the first plugin of each cycle (0–2048 px).\nDefault: 0. Anything approaching your screen width reads as the display switching off at the start of every cycle.', 'Lead-In Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_lead_in_width"
|
||||
name="vegas_lead_in_width"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('lead_in_width', 0) }}"
|
||||
min="0"
|
||||
max="2048"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div class="form-group" id="setting-display-vegas_trim_threshold" data-setting-key="display.vegas_scroll.trim_threshold">
|
||||
<label for="vegas_trim_threshold" class="block text-sm font-medium text-gray-700">Trim Threshold{{ ui.help_tip('How bright a pixel must be to count as content rather than empty space (0–254).\nDefault: 10, which ignores the near-black noise left by image compression. Raise it if very dark artwork is being kept; lower it if dark detail is being cropped.', 'Trim Threshold') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_trim_threshold"
|
||||
name="vegas_trim_threshold"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('trim_threshold', 10) }}"
|
||||
min="0"
|
||||
max="254"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_min_plugin_width" data-setting-key="display.vegas_scroll.min_plugin_width">
|
||||
<label for="vegas_min_plugin_width" class="block text-sm font-medium text-gray-700">Min Plugin Width (pixels){{ ui.help_tip('Plugin blocks narrower than this after trimming are skipped for that cycle (0–512 px).\nDefault: 8. Raise it to hide plugins showing only a tiny placeholder such as "No Data" until they have real content.', 'Min Plugin Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_plugin_width"
|
||||
name="vegas_min_plugin_width"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_plugin_width', 8) }}"
|
||||
min="0"
|
||||
max="512"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div class="form-group" id="setting-display-vegas_min_cut_gap" data-setting-key="display.vegas_scroll.min_cut_gap">
|
||||
<label for="vegas_min_cut_gap" class="block text-sm font-medium text-gray-700">Min Cut Gap (pixels){{ ui.help_tip('When a plugin is too wide for its share of a cycle and has to be narrowed, the cut is only made where there is at least this much blank space (1–128 px).\nDefault: 6. The gaps between letters are about 1px wide, so a smaller value lets a cut land inside a word and orphan its last letter into the next cycle. Raise it if cuts still land awkwardly. Continuous images such as maps have no gaps and are cut to fit regardless.', 'Min Cut Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_cut_gap"
|
||||
name="vegas_min_cut_gap"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cut_gap', 6) }}"
|
||||
min="1"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Order Section -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>
|
||||
@@ -454,6 +704,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Double-Sided Display -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">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.</p>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox"
|
||||
id="double_sided_enabled"
|
||||
name="double_sided_enabled"
|
||||
value="true"
|
||||
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
|
||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Hidden (not disabled) when the feature is off, so the values are
|
||||
still submitted and round-trip through a save. -->
|
||||
<div id="double_sided_settings"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
{% if not main_config.display.get('double_sided', {}).get('enabled') %}style="display: none;"{% endif %}>
|
||||
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
|
||||
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (2–8).\nWhen enabled, this must divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
|
||||
<input type="number"
|
||||
id="double_sided_copies"
|
||||
name="double_sided_copies"
|
||||
value="{{ main_config.display.get('double_sided', {}).get('copies', 2) }}"
|
||||
min="2"
|
||||
max="8"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
|
||||
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
|
||||
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
|
||||
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal — chained panels (side by side)</option>
|
||||
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — parallel chains (stacked)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Multi-Display Sync Settings -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -549,6 +845,28 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
|
||||
};
|
||||
}
|
||||
|
||||
// Report the outcome of a display-settings save. XMLHttpRequest has no
|
||||
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
|
||||
// status code, otherwise a failed save reports success.
|
||||
window.showDisplaySaveResult = function(xhr) {
|
||||
// Only 2xx counts as saved. A network failure reports status 0, which any
|
||||
// `>= 400` test would wave through as success.
|
||||
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
|
||||
let message = httpSuccess
|
||||
? 'Display settings saved'
|
||||
: 'Display settings were not saved. Check your connection and try again.';
|
||||
let status = httpSuccess ? 'success' : 'error';
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (data.message) message = data.message;
|
||||
// A body can refine a successful verdict but never overturn a failed one.
|
||||
if (httpSuccess && data.status) status = data.status;
|
||||
} catch {
|
||||
// Non-JSON body — fall back to the status-code verdict above.
|
||||
}
|
||||
showNotification(message, status);
|
||||
};
|
||||
|
||||
// Vegas Scroll Mode Settings
|
||||
(function() {
|
||||
// Escape HTML to prevent XSS
|
||||
@@ -573,6 +891,18 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
|
||||
});
|
||||
}
|
||||
|
||||
// Double-sided: copies/axis only mean anything while the feature is on.
|
||||
// Hidden rather than disabled so the fields keep submitting and the server
|
||||
// still sees an 'off' state to persist.
|
||||
const doubleSidedCheckbox = document.getElementById('double_sided_enabled');
|
||||
const doubleSidedSettings = document.getElementById('double_sided_settings');
|
||||
|
||||
if (doubleSidedCheckbox && doubleSidedSettings) {
|
||||
doubleSidedCheckbox.addEventListener('change', function() {
|
||||
doubleSidedSettings.style.display = this.checked ? 'grid' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Update scroll speed display
|
||||
const scrollSpeedSlider = document.getElementById('vegas_scroll_speed');
|
||||
const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value');
|
||||
@@ -583,183 +913,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 = '<p class="text-sm text-gray-500 italic">No plugins available</p>';
|
||||
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 += `
|
||||
<div class="flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move vegas-plugin-item"
|
||||
data-plugin-id="${safePluginId}" draggable="true">
|
||||
<i class="fas fa-grip-vertical text-gray-400 mr-3"></i>
|
||||
<label class="flex items-center flex-1">
|
||||
<input type="checkbox"
|
||||
class="vegas-plugin-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2"
|
||||
${!isExcluded ? 'checked' : ''}>
|
||||
<span class="text-sm font-medium text-gray-700">${safePluginName}</span>
|
||||
</label>
|
||||
<span class="text-xs ${modeInfo.color} ml-2" title="Vegas display mode: ${modeInfo.label}">
|
||||
<i class="fas ${modeInfo.icon} mr-1"></i>${modeInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html || '<p class="text-sm text-gray-500 italic">No enabled plugins</p>';
|
||||
|
||||
// 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 = '<p class="text-sm text-red-500">Error loading plugins</p>';
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% import 'v3/partials/_macros.html' as ui %}
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Display Durations</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Configure how long each screen is shown before switching. Values in seconds.</p>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Rotation & Durations</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Set the order plugins rotate on the display and how long each screen is shown. Durations are in seconds.</p>
|
||||
</div>
|
||||
|
||||
{{ ui.settings_filter() }}
|
||||
@@ -16,22 +16,53 @@
|
||||
novalidate
|
||||
onsubmit="fixInvalidNumberInputs(this); return true;">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for key, value in main_config.display.display_durations.items() %}
|
||||
<div class="form-group" id="setting-durations-{{ key }}" data-setting-key="display.display_durations.{{ key }}">
|
||||
<label for="duration_{{ key }}" class="block text-sm font-medium text-gray-700">
|
||||
{{ key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600. Currently ' ~ value ~ 's.', key | replace('_', ' ') | title) }}
|
||||
</label>
|
||||
<input type="number"
|
||||
id="duration_{{ key }}"
|
||||
name="{{ key }}"
|
||||
value="{{ value }}"
|
||||
min="5"
|
||||
max="600"
|
||||
class="form-control">
|
||||
<!-- Primary rotation order: drag to reorder which plugin shows first,
|
||||
second, ... in the normal display rotation. Saved as
|
||||
display.plugin_rotation_order and applied by the display
|
||||
controller on startup and live plugin enable/disable. -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Rotation Order</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">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.</p>
|
||||
<div id="rotation_plugin_order" class="space-y-2 bg-white rounded-lg p-3 border border-gray-200">
|
||||
<p class="text-sm text-gray-500 italic">Loading plugins…</p>
|
||||
</div>
|
||||
<input type="hidden" id="rotation_plugin_order_value" name="plugin_rotation_order"
|
||||
value='{{ main_config.display.get("plugin_rotation_order", [])|tojson }}'>
|
||||
</div>
|
||||
|
||||
{% if duration_groups %}
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-5">
|
||||
<div>
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Screen Durations</h3>
|
||||
<p class="text-sm text-gray-600">How long each screen stays on before rotating to the next one, in seconds (5–600, default 30).</p>
|
||||
</div>
|
||||
{% for group in duration_groups %}
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-800 mb-2">{{ group.plugin_name }}</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for mode in group.modes %}
|
||||
<div class="form-group" id="setting-durations-{{ mode.key }}" data-setting-key="display.display_durations.{{ mode.key }}">
|
||||
<label for="duration__{{ mode.key }}" class="block text-sm font-medium text-gray-700">
|
||||
{{ mode.key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (mode.key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600. Currently ' ~ mode.value ~ 's.', mode.key | replace('_', ' ') | title) }}
|
||||
</label>
|
||||
<input type="number"
|
||||
id="duration__{{ mode.key }}"
|
||||
name="duration__{{ mode.key }}"
|
||||
value="{{ mode.value }}"
|
||||
min="5"
|
||||
max="600"
|
||||
class="form-control">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-500 italic">No enabled plugins found — enable a plugin in the Plugin Manager to set its screen durations here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
@@ -43,3 +74,34 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Shared drag-and-drop plugin list (static/v3/js/widgets/plugin-order-list.js,
|
||||
// same module the Vegas Scroll section uses).
|
||||
function initRotationOrderList(attempt) {
|
||||
const container = document.getElementById('rotation_plugin_order');
|
||||
if (!container) return;
|
||||
if (!window.PluginOrderList) {
|
||||
// Widget script is deferred; retry briefly, then surface a real
|
||||
// error instead of showing "Loading…" forever.
|
||||
if ((attempt || 0) < 50) {
|
||||
setTimeout(function() { initRotationOrderList((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: 'rotation_plugin_order',
|
||||
orderInputId: 'rotation_plugin_order_value'
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initRotationOrderList);
|
||||
} else {
|
||||
initRotationOrderList();
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
|
||||
@@ -49,6 +49,18 @@
|
||||
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
|
||||
<div id="timezone_container" class="mt-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll frame rate (device-wide) -->
|
||||
<div class="form-group" id="setting-general-target-fps" data-setting-key="target_fps">
|
||||
<label for="target_fps" class="block text-sm font-medium text-gray-700">Scroll Frame Rate{{ ui.help_tip('Frames per second for scrolling content, applied across plugins that scroll.\nHigher is smoother but uses more CPU; lower frees CPU but looks steppier.\nRange 30-200. Default: 100.', 'Scroll Frame Rate') }}</label>
|
||||
<input type="number"
|
||||
id="target_fps"
|
||||
name="target_fps"
|
||||
value="{{ main_config.target_fps or 100 }}"
|
||||
min="30"
|
||||
max="200"
|
||||
class="form-control">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
// Track if already initialized to prevent re-render
|
||||
@@ -83,7 +95,7 @@
|
||||
<!-- Location Information -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
|
||||
<div class="form-group" id="setting-general-city" data-setting-key="location.city">
|
||||
<label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, and other location-based content.\nExample: Dallas.', 'City') }}</label>
|
||||
<label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, radar, and other location-based content.\nExample: Kansas City.\nUsed as the default for the location_city setting on plugins that have one; a value saved on the plugin itself overrides it.', 'City') }}</label>
|
||||
<input type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
@@ -92,7 +104,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-general-state" data-setting-key="location.state">
|
||||
<label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Texas. Improves location-lookup accuracy.', 'State') }}</label>
|
||||
<label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Missouri. Improves location-lookup accuracy.\nUsed as the default for the location_state setting on plugins that have one.', 'State') }}</label>
|
||||
<input type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
@@ -101,7 +113,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-general-country" data-setting-key="location.country">
|
||||
<label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather and geolocation.', 'Country') }}</label>
|
||||
<label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather, radar, and geolocation.\nUsed as the default for the location_country setting on plugins that have one.', 'Country') }}</label>
|
||||
<input type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
<p class="mt-1 text-sm text-gray-600">View real-time logs from the LED matrix service for troubleshooting.</p>
|
||||
</div>
|
||||
|
||||
<!-- Currently displayed plugin -->
|
||||
<div id="current-plugin-banner" class="hidden mb-4 flex items-center gap-2 text-sm bg-blue-50 border border-blue-200 text-blue-800 rounded-lg px-3 py-2">
|
||||
<i class="fas fa-tv"></i>
|
||||
<span>Now showing: <strong id="current-plugin-mode">-</strong>
|
||||
<span id="current-plugin-id-wrap" class="hidden">(plugin: <code id="current-plugin-id" class="bg-blue-100 px-1 rounded"></code>)</span>
|
||||
</span>
|
||||
<button id="current-plugin-filter-btn" class="hidden ml-auto btn bg-blue-600 hover:bg-blue-700 text-white px-2 py-0.5 rounded text-xs">
|
||||
Filter to this plugin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
@@ -27,6 +38,11 @@
|
||||
<option value="INFO">Info & Above</option>
|
||||
</select>
|
||||
|
||||
<!-- Plugin Filter -->
|
||||
<select id="log-plugin-filter" class="form-control text-sm">
|
||||
<option value="">All Plugins</option>
|
||||
</select>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input type="text" id="log-search" placeholder="Search logs..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
|
||||
@@ -139,6 +155,7 @@ window._filteredLogs = [];
|
||||
const realtimeToggle = document.getElementById('log-realtime-toggle');
|
||||
const refreshBtn = document.getElementById('refresh-logs-btn');
|
||||
const levelFilter = document.getElementById('log-level-filter');
|
||||
const pluginFilter = document.getElementById('log-plugin-filter');
|
||||
const searchInput = document.getElementById('log-search');
|
||||
const autoscrollToggle = document.getElementById('log-autoscroll');
|
||||
const clearBtn = document.getElementById('clear-logs-btn');
|
||||
@@ -160,6 +177,11 @@ window._filteredLogs = [];
|
||||
levelFilter.parentNode.replaceChild(newFilter, levelFilter);
|
||||
newFilter.addEventListener('change', filterLogs);
|
||||
}
|
||||
if (pluginFilter) {
|
||||
const newFilter = pluginFilter.cloneNode(true);
|
||||
pluginFilter.parentNode.replaceChild(newFilter, pluginFilter);
|
||||
newFilter.addEventListener('change', filterLogs);
|
||||
}
|
||||
if (searchInput) {
|
||||
const newInput = searchInput.cloneNode(true);
|
||||
searchInput.parentNode.replaceChild(newInput, searchInput);
|
||||
@@ -180,6 +202,24 @@ window._filteredLogs = [];
|
||||
downloadBtn.parentNode.replaceChild(newBtn, downloadBtn);
|
||||
newBtn.addEventListener('click', downloadLogs);
|
||||
}
|
||||
const currentPluginFilterBtn = document.getElementById('current-plugin-filter-btn');
|
||||
if (currentPluginFilterBtn) {
|
||||
const newBtn = currentPluginFilterBtn.cloneNode(true);
|
||||
currentPluginFilterBtn.parentNode.replaceChild(newBtn, currentPluginFilterBtn);
|
||||
newBtn.addEventListener('click', function() {
|
||||
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||||
if (pluginFilterEl && window._currentPluginId) {
|
||||
pluginFilterEl.value = window._currentPluginId;
|
||||
filterLogs();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshCurrentPluginStatus();
|
||||
if (window._currentPluginPollTimer) {
|
||||
clearInterval(window._currentPluginPollTimer);
|
||||
}
|
||||
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
|
||||
|
||||
// Handle window resize for responsive height
|
||||
window.addEventListener('resize', function() {
|
||||
@@ -284,37 +324,25 @@ function processLogs(logsText, append = false) {
|
||||
// Skip empty lines
|
||||
if (!line.trim()) return;
|
||||
|
||||
// Try to parse journalctl format: "MMM DD HH:MM:SS hostname service[pid]: message"
|
||||
// Example: "Oct 13 14:23:45 raspberrypi ledmatrix[1234]: INFO: Starting display"
|
||||
|
||||
// journalctl (--output=short-iso) emits: "YYYY-MM-DDTHH:MM:SS+ZZZZ hostname service[pid]: message"
|
||||
// Example: "2024-01-15T10:23:45+0000 raspberrypi ledmatrix[1234]: INFO - plugin.nhl_scoreboard - [Plugin: nhl_scoreboard] Updated scores"
|
||||
// Also accept the older syslog-style "MMM DD HH:MM:SS" timestamp for compatibility.
|
||||
|
||||
let timestamp = '';
|
||||
let level = 'INFO';
|
||||
let message = line;
|
||||
|
||||
// Extract timestamp (first part before hostname)
|
||||
const timestampMatch = line.match(/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/);
|
||||
if (timestampMatch) {
|
||||
timestamp = timestampMatch[1];
|
||||
|
||||
// Find the message part (after service name and pid)
|
||||
const messageMatch = line.match(/:\s*(.+)$/);
|
||||
if (messageMatch) {
|
||||
message = messageMatch[1];
|
||||
|
||||
// Detect log level from message
|
||||
if (message.match(/\b(ERROR|CRITICAL|FATAL)\b/i)) {
|
||||
level = 'ERROR';
|
||||
} else if (message.match(/\b(WARNING|WARN)\b/i)) {
|
||||
level = 'WARNING';
|
||||
} else if (message.match(/\bDEBUG\b/i)) {
|
||||
level = 'DEBUG';
|
||||
} else if (message.match(/\bINFO\b/i)) {
|
||||
level = 'INFO';
|
||||
}
|
||||
|
||||
// Clean up level prefix from message if it exists
|
||||
message = message.replace(/^(ERROR|WARNING|WARN|INFO|DEBUG):\s*/i, '');
|
||||
}
|
||||
let plugin = '';
|
||||
let rest = null;
|
||||
|
||||
const isoMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z))\s+(.*)$/);
|
||||
const syslogMatch = !isoMatch && line.match(/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+(.*)$/);
|
||||
|
||||
if (isoMatch) {
|
||||
timestamp = isoMatch[1];
|
||||
rest = isoMatch[2];
|
||||
} else if (syslogMatch) {
|
||||
timestamp = syslogMatch[1];
|
||||
rest = syslogMatch[2];
|
||||
} else {
|
||||
// If no timestamp, use current time
|
||||
timestamp = new Date().toLocaleString('en-US', {
|
||||
@@ -327,14 +355,58 @@ function processLogs(logsText, append = false) {
|
||||
});
|
||||
}
|
||||
|
||||
if (rest !== null) {
|
||||
// Find the message part (after hostname + service name/pid)
|
||||
const messageMatch = rest.match(/:\s*(.+)$/);
|
||||
if (messageMatch) {
|
||||
message = messageMatch[1];
|
||||
|
||||
// Detect log level from message
|
||||
if (message.match(/\b(ERROR|CRITICAL|FATAL)\b/i)) {
|
||||
level = 'ERROR';
|
||||
} else if (message.match(/\b(WARNING|WARN)\b/i)) {
|
||||
level = 'WARNING';
|
||||
} else if (message.match(/\bDEBUG\b/i)) {
|
||||
level = 'DEBUG';
|
||||
} else if (message.match(/\bINFO\b/i)) {
|
||||
level = 'INFO';
|
||||
}
|
||||
|
||||
// Clean up level prefix from message if it exists
|
||||
message = message.replace(/^(ERROR|WARNING|WARN|INFO|DEBUG)\s*[-:]\s*/i, '');
|
||||
|
||||
// journalctl already carries its own timestamp; strip the app's
|
||||
// internal "YYYY-MM-DD HH:MM:SS.mmm - LEVEL - logger.name - "
|
||||
// prefix (see ContextualFormatter in src/logging_config.py) so
|
||||
// it isn't duplicated in the displayed message.
|
||||
message = message.replace(
|
||||
/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*-\s*(ERROR|WARNING|WARN|INFO|DEBUG|CRITICAL)\s*-\s*[\w.]+\s*-\s*/i,
|
||||
''
|
||||
);
|
||||
|
||||
// Extract the "[Plugin: <id>]" context tag emitted by
|
||||
// ContextualFormatter for every plugin logger (see
|
||||
// src/logging_config.py PluginLoggerAdapter), and pull it out
|
||||
// of the displayed message into its own field.
|
||||
const pluginMatch = message.match(/^\[Plugin:\s*([^\]]+)\]\s*/);
|
||||
if (pluginMatch) {
|
||||
plugin = pluginMatch[1].trim();
|
||||
message = message.replace(pluginMatch[0], '');
|
||||
}
|
||||
} else {
|
||||
message = rest;
|
||||
}
|
||||
}
|
||||
|
||||
const logEntry = {
|
||||
timestamp: timestamp,
|
||||
level: level,
|
||||
plugin: plugin,
|
||||
message: message,
|
||||
raw: line,
|
||||
id: Date.now() + Math.random()
|
||||
};
|
||||
|
||||
|
||||
// Don't add duplicate entries when appending
|
||||
if (!append || !window._allLogs.find(log => log.raw === line)) {
|
||||
window._allLogs.push(logEntry);
|
||||
@@ -346,9 +418,26 @@ function processLogs(logsText, append = false) {
|
||||
window._allLogs = window._allLogs.slice(-window._MAX_LOGS);
|
||||
}
|
||||
|
||||
updatePluginFilterOptions();
|
||||
filterLogs();
|
||||
}
|
||||
|
||||
function updatePluginFilterOptions() {
|
||||
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||||
if (!pluginFilterEl) return;
|
||||
|
||||
const previousValue = pluginFilterEl.value;
|
||||
const plugins = Array.from(new Set(window._allLogs.map(log => log.plugin).filter(Boolean))).sort();
|
||||
|
||||
pluginFilterEl.innerHTML = '<option value="">All Plugins</option>' +
|
||||
plugins.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
|
||||
|
||||
// Restore previous selection if it's still a valid option
|
||||
if (previousValue && plugins.includes(previousValue)) {
|
||||
pluginFilterEl.value = previousValue;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
if (window._filteredLogs.length === 0) {
|
||||
showEmptyState();
|
||||
@@ -364,10 +453,14 @@ function renderLogs() {
|
||||
window._filteredLogs.forEach(log => {
|
||||
const logElement = document.createElement('div');
|
||||
logElement.className = `log-entry py-1 px-2 hover:bg-gray-800 rounded transition-colors duration-150 ${getLogLevelClass(log.level)}`;
|
||||
const pluginBadge = log.plugin
|
||||
? `<span class="log-plugin flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold bg-purple-700 text-white" title="Plugin: ${escapeHtml(log.plugin)}">${escapeHtml(log.plugin)}</span>`
|
||||
: '';
|
||||
logElement.innerHTML = `
|
||||
<div class="flex items-start gap-3 text-xs font-mono">
|
||||
<span class="log-timestamp text-gray-400 flex-shrink-0 w-32">${escapeHtml(log.timestamp)}</span>
|
||||
<span class="log-level flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${getLogLevelBadgeClass(log.level)}">${log.level}</span>
|
||||
${pluginBadge}
|
||||
<span class="log-message flex-1 ${getLogLevelTextClass(log.level)} break-words">${escapeHtml(log.message)}</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -410,10 +503,12 @@ function getLogLevelTextClass(level) {
|
||||
|
||||
function filterLogs() {
|
||||
const levelFilterEl = document.getElementById('log-level-filter');
|
||||
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||||
const searchEl = document.getElementById('log-search');
|
||||
if (!levelFilterEl || !searchEl) return;
|
||||
|
||||
|
||||
const levelFilter = levelFilterEl.value;
|
||||
const pluginFilter = pluginFilterEl ? pluginFilterEl.value : '';
|
||||
const searchTerm = searchEl.value.toLowerCase();
|
||||
|
||||
window._filteredLogs = window._allLogs.filter(log => {
|
||||
@@ -430,8 +525,14 @@ function filterLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin filter
|
||||
if (pluginFilter && log.plugin !== pluginFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchTerm && !log.message.toLowerCase().includes(searchTerm)) {
|
||||
if (searchTerm && !log.message.toLowerCase().includes(searchTerm) &&
|
||||
!(log.plugin && log.plugin.toLowerCase().includes(searchTerm))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -617,10 +718,45 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function refreshCurrentPluginStatus() {
|
||||
fetch('/api/v3/display/current-status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success' || !data.data) return;
|
||||
const state = data.data;
|
||||
const banner = document.getElementById('current-plugin-banner');
|
||||
const modeEl = document.getElementById('current-plugin-mode');
|
||||
const idWrap = document.getElementById('current-plugin-id-wrap');
|
||||
const idEl = document.getElementById('current-plugin-id');
|
||||
const filterBtn = document.getElementById('current-plugin-filter-btn');
|
||||
if (!banner || !modeEl) return;
|
||||
|
||||
window._currentPluginId = state.plugin_id || null;
|
||||
|
||||
modeEl.textContent = state.mode || 'unknown';
|
||||
if (state.plugin_id) {
|
||||
idEl.textContent = state.plugin_id;
|
||||
idWrap.classList.remove('hidden');
|
||||
if (filterBtn) filterBtn.classList.remove('hidden');
|
||||
} else {
|
||||
idWrap.classList.add('hidden');
|
||||
if (filterBtn) filterBtn.classList.add('hidden');
|
||||
}
|
||||
banner.classList.remove('hidden');
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore - banner just stays hidden/stale
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window._logsEventSource) {
|
||||
window._logsEventSource.close();
|
||||
}
|
||||
if (window._currentPluginPollTimer) {
|
||||
clearInterval(window._currentPluginPollTimer);
|
||||
window._currentPluginPollTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -61,6 +61,234 @@
|
||||
}());
|
||||
</script>
|
||||
|
||||
<!-- Getting Started checklist: non-gating, dismissible (localStorage), items
|
||||
auto-check from existing config/endpoints — no new persisted state.
|
||||
The timezone step is verified against the browser's own zone rather than
|
||||
compared to the shipped default; see the data-check="timezone" block below
|
||||
for why. -->
|
||||
{% 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 _tz = (main_config.timezone if main_config else '') or '' %}
|
||||
<div id="getting-started-card" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4" style="display:none" role="region" aria-label="Getting started checklist">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p>
|
||||
<p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p>
|
||||
<ul class="space-y-1 text-sm" id="getting-started-items">
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
|
||||
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general">
|
||||
<i class="far fa-square mr-2"></i>Set your timezone{% if _tz %} — currently {{ _tz }}{% if _loc.city %}, {{ _loc.city }}{% endif %}{% endif %} (General tab)<span data-gs-tz-note class="text-xs"></span></button></li>
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="installed" data-tab="plugins">
|
||||
<i class="far fa-square mr-2"></i>Install a plugin from the Plugin Store</button></li>
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="enabled" data-tab="plugins">
|
||||
<i class="far fa-square mr-2"></i>Enable a plugin</button></li>
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="configured" data-tab="plugins">
|
||||
<i class="far fa-square mr-2"></i>Configure it (each plugin gets its own tab)</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" onclick="window.dismissGettingStarted()" class="ml-4 flex-shrink-0 text-blue-400 hover:text-blue-600" aria-label="Dismiss getting started checklist">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var KEY = 'ledmatrix-getting-started-dismissed';
|
||||
var MANUAL_KEY = 'ledmatrix-getting-started-manual';
|
||||
var card = document.getElementById('getting-started-card');
|
||||
if (!card) return;
|
||||
try { if (localStorage.getItem(KEY) === '1') return; } catch (e) {}
|
||||
card.style.display = 'block';
|
||||
|
||||
// Manual per-item overrides: the auto-detection is heuristic (a value
|
||||
// saved AT its default — e.g. a user genuinely in the default timezone —
|
||||
// reads as "not done"), so clicking an item's checkbox marks it done by
|
||||
// hand, persisted per browser.
|
||||
var manual = {};
|
||||
try { manual = JSON.parse(localStorage.getItem(MANUAL_KEY) || '{}') || {}; } catch (e) {}
|
||||
function saveManual() {
|
||||
try { localStorage.setItem(MANUAL_KEY, JSON.stringify(manual)); } catch (e) {}
|
||||
}
|
||||
|
||||
function setDone(btn, done) {
|
||||
btn.dataset.done = done ? '1' : '0';
|
||||
var icon = btn.querySelector('i');
|
||||
if (icon) { icon.className = done ? 'fas fa-check-square mr-2 text-green-600' : 'far fa-square mr-2'; }
|
||||
btn.classList.toggle('text-gray-500', done);
|
||||
btn.classList.toggle('line-through', done);
|
||||
}
|
||||
|
||||
// Once every step is done (auto-detected or manually checked), the card
|
||||
// has served its purpose — hide it without requiring an explicit dismiss.
|
||||
function maybeAutoHide() {
|
||||
var items = card.querySelectorAll('.gs-item');
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].dataset.done !== '1') return;
|
||||
}
|
||||
card.style.display = 'none';
|
||||
}
|
||||
|
||||
function markDone(btn) {
|
||||
if (!btn) return;
|
||||
setDone(btn, true);
|
||||
maybeAutoHide();
|
||||
}
|
||||
|
||||
// Apply server-derived + manual states, wire deep links (same app-data
|
||||
// access pattern as settings-search.js). Clicking the checkbox icon
|
||||
// toggles manual done; clicking the text deep-links to the tab.
|
||||
Array.prototype.forEach.call(card.querySelectorAll('.gs-item'), function (btn, idx) {
|
||||
if (manual[idx] === 1) btn.dataset.done = '1';
|
||||
if (btn.dataset.done === '1') setDone(btn, true);
|
||||
btn.addEventListener('click', function (ev) {
|
||||
var icon = btn.querySelector('i');
|
||||
if (icon && ev.target === icon) {
|
||||
var nowDone = btn.dataset.done !== '1';
|
||||
setDone(btn, nowDone);
|
||||
manual[idx] = nowDone ? 1 : 0;
|
||||
saveManual();
|
||||
if (nowDone) maybeAutoHide();
|
||||
return;
|
||||
}
|
||||
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
||||
// Same two-tier resolution as settings-search.js's getAppData():
|
||||
// _x_dataStack on current Alpine, __x.$data as an older-API fallback.
|
||||
var data = appEl && ((appEl._x_dataStack && appEl._x_dataStack[0]) ||
|
||||
(appEl.__x && appEl.__x.$data));
|
||||
if (data) {
|
||||
data.activeTab = btn.dataset.tab;
|
||||
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
maybeAutoHide();
|
||||
|
||||
// Timezone: verified against the browser's own zone.
|
||||
//
|
||||
// This step used to tick when the saved timezone differed from the value
|
||||
// config.template.json ships (America/New_York), with the saved city
|
||||
// OR-ed in. Two things were wrong with that. "Differs from the default"
|
||||
// answers "did somebody edit this?", but what the checklist needs to know
|
||||
// is whether the value is RIGHT — so anyone who genuinely lives in the
|
||||
// default zone could never satisfy it and the card nagged forever. And
|
||||
// the city has no bearing on whether the timezone is set: because the two
|
||||
// were OR-ed, saving a city ticked the step off with the timezone still
|
||||
// wrong, which is the direction that actually breaks displays (event
|
||||
// times render in the wrong zone).
|
||||
//
|
||||
// The browser already knows its zone, so compare against that: no new
|
||||
// persisted state, no network, and it catches the reverse case too — a
|
||||
// panel still set to the old zone after a move now stays unticked, where
|
||||
// the old test ticked it the moment the value stopped being the default.
|
||||
function sameZone(a, b) {
|
||||
if (a === b) return true;
|
||||
// Compare the wall-clock time each zone yields, not the identifiers:
|
||||
// aliases (Asia/Calcutta vs Asia/Kolkata, Europe/Kiev vs Europe/Kyiv)
|
||||
// name one zone and must not read as a mismatch.
|
||||
//
|
||||
// Sampled at three instants, all of which have to agree. Checking only
|
||||
// now is not enough: America/New_York and America/Lima hold the same
|
||||
// offset all winter, so a panel set to the wrong one of those would
|
||||
// tick in January and then run an hour off from March. Mid-January and
|
||||
// mid-July sit either side of DST in both hemispheres, so only zones
|
||||
// that agree year-round match -- while Toronto still matches New York,
|
||||
// which is right, since either renders the same times.
|
||||
try {
|
||||
var now = new Date();
|
||||
var year = now.getUTCFullYear();
|
||||
var instants = [now,
|
||||
new Date(Date.UTC(year, 0, 15, 12)),
|
||||
new Date(Date.UTC(year, 6, 15, 12))];
|
||||
var stamp = function (tz, at) {
|
||||
// Explicit numeric fields rather than dateStyle/timeStyle:
|
||||
// those are late additions to Intl (Firefox shipped them in
|
||||
// 91), and an implementation that does not know them ignores
|
||||
// them and formats the date alone. That would compare
|
||||
// New York, Chicago and Madrid as equal and tick the step for
|
||||
// a timezone that is plainly wrong -- the exact failure this
|
||||
// check exists to catch. These options have been in Intl
|
||||
// since ECMA-402 v1.
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz, year: 'numeric', month: '2-digit',
|
||||
day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
hour12: false
|
||||
}).format(at);
|
||||
};
|
||||
for (var i = 0; i < instants.length; i++) {
|
||||
if (stamp(a, instants[i]) !== stamp(b, instants[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
// An unparseable zone in the config is worth surfacing, not hiding.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
(function () {
|
||||
var tzBtn = card.querySelector('[data-check="timezone"]');
|
||||
if (!tzBtn) return;
|
||||
var configured = tzBtn.dataset.tz || '';
|
||||
if (!configured) return; // nothing saved yet: leave it open
|
||||
var local = '';
|
||||
try {
|
||||
local = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
|
||||
} catch (e) {
|
||||
return; // no Intl: leave it to the manual tick
|
||||
}
|
||||
if (!local) return;
|
||||
if (sameZone(configured, local)) {
|
||||
markDone(tzBtn);
|
||||
return;
|
||||
}
|
||||
// Unticked on its own says "wrong" without saying why; name the zone
|
||||
// the browser is in so the step is actionable.
|
||||
var note = tzBtn.querySelector('[data-gs-tz-note]');
|
||||
if (note) note.textContent = ' — this browser is in ' + local;
|
||||
}());
|
||||
|
||||
// Plugin-derived states from the existing installed-plugins endpoint.
|
||||
fetch('/api/v3/plugins/installed')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (resp) {
|
||||
var plugins = (resp.data && resp.data.plugins) || [];
|
||||
if (plugins.length > 0) markDone(card.querySelector('[data-check="installed"]'));
|
||||
var enabled = plugins.filter(function (p) { return p.enabled; });
|
||||
if (enabled.length > 0) {
|
||||
markDone(card.querySelector('[data-check="enabled"]'));
|
||||
// "Configured" heuristic: the first enabled plugin has at least
|
||||
// one saved value that differs from its schema defaults.
|
||||
var pid = enabled[0].id;
|
||||
Promise.all([
|
||||
fetch('/api/v3/plugins/config?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); }),
|
||||
fetch('/api/v3/plugins/schema?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); })
|
||||
]).then(function (res) {
|
||||
// GET /plugins/config returns the config dict directly in .data
|
||||
var cfg = res[0].data || {};
|
||||
var props = (res[1].data && res[1].data.schema && res[1].data.schema.properties) || {};
|
||||
for (var k in cfg) {
|
||||
if (k === 'enabled' || !(k in props)) continue;
|
||||
if (props[k] && 'default' in props[k] &&
|
||||
JSON.stringify(cfg[k]) !== JSON.stringify(props[k]['default'])) {
|
||||
markDone(card.querySelector('[data-check="configured"]'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
|
||||
window.dismissGettingStarted = function () {
|
||||
card.style.display = 'none';
|
||||
try { localStorage.setItem(KEY, '1'); } catch (e) {}
|
||||
};
|
||||
}());
|
||||
</script>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
||||
@@ -221,7 +449,10 @@
|
||||
<h3 class="text-md font-medium text-gray-900 mb-4">
|
||||
<i class="fas fa-desktop"></i> Live Display Preview
|
||||
</h3>
|
||||
<div class="bg-gray-900 rounded-lg p-6 border border-gray-700" style="position: relative;">
|
||||
<!-- overflow-x-auto: on narrow screens a wide preview scrolls at its
|
||||
true pixel-perfect size instead of being squeezed (fractional
|
||||
downscaling of pixel art reads as blur) -->
|
||||
<div class="bg-gray-900 rounded-lg p-6 border border-gray-700 overflow-x-auto" style="position: relative;">
|
||||
<div id="previewStage" class="preview-stage" style="display:none; position:relative; display:inline-block;">
|
||||
<div id="previewMeta" style="position:absolute; top:-28px; left:0; color:#ddd; font-size:12px; opacity:0.85;"></div>
|
||||
<img id="displayImage" style="image-rendering: pixelated; display: block;" alt="LED Matrix Display">
|
||||
|
||||
@@ -121,14 +121,21 @@
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{# Enum dropdown #}
|
||||
{# Enum dropdown. Option text comes from x-options.labels when the
|
||||
schema supplies it -- the same convention the checkbox-group
|
||||
widget already uses -- because humanising the raw value cannot
|
||||
express every label: "vs" reads as "Vs", and "abbrev" says
|
||||
nothing about the "Sep 19" it produces. Values without a label
|
||||
fall back to the humanised form, so existing schemas render
|
||||
exactly as before. #}
|
||||
{% elif prop.enum %}
|
||||
{% set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} %}
|
||||
<select id="{{ field_id }}"
|
||||
name="{{ full_key }}"
|
||||
name="{{ full_key }}"
|
||||
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black">
|
||||
{% for option in prop.enum %}
|
||||
<option value="{{ option }}" {% if value == option %}selected{% endif %}>
|
||||
{{ option|replace('_', ' ')|title }}
|
||||
{{ enum_labels.get(option, option|replace('_', ' ')|title) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -289,7 +296,27 @@
|
||||
{% set enum_items = items_schema.get('enum') or [] %}
|
||||
{% set x_options = prop.get('x-options') or {} %}
|
||||
{% set labels = x_options.get('labels') or {} %}
|
||||
|
||||
{# A saved value that is no longer one of the options -- a team
|
||||
code the league retired, an option dropped from the schema --
|
||||
has no checkbox to render, so it would sit unseen in the
|
||||
hidden input below and be posted back on save. The schema
|
||||
rejects it and the save endpoint returns 400, which blocks
|
||||
editing any other field on the plugin until the stale entry
|
||||
is found and removed. Drop them here instead, and say which,
|
||||
so the value is not lost silently. Only when the widget
|
||||
actually has options: an empty enum means nothing to check
|
||||
against, and filtering on it would wipe the field. #}
|
||||
{% set stale_values = (array_value | reject('in', enum_items) | list) if enum_items else [] %}
|
||||
{% set array_value = (array_value | select('in', enum_items) | list) if enum_items else array_value %}
|
||||
|
||||
{% if stale_values %}
|
||||
<div class="mt-1 mb-2 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800"
|
||||
data-stale-options="{{ field_id }}">
|
||||
No longer offered, and will be removed when you save:
|
||||
<span class="font-mono">{{ stale_values | join(', ') }}</span>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-1 space-y-2">
|
||||
{% for option in enum_items %}
|
||||
{% set is_checked = option in array_value %}
|
||||
@@ -569,10 +596,14 @@
|
||||
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
||||
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
|
||||
{% elif col_enum %}
|
||||
{# Labels are opt-in here and the fallback stays the raw
|
||||
value: table columns hold things like ticker symbols,
|
||||
which must not be title-cased behind the user's back. #}
|
||||
{% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %}
|
||||
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
|
||||
{% for opt in col_enum %}{% if opt is not none %}
|
||||
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ opt }}</option>
|
||||
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option>
|
||||
{% endif %}{% endfor %}
|
||||
</select>
|
||||
{% elif col_xwidget == 'date-picker' %}
|
||||
@@ -804,7 +835,7 @@
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Changes in the file manager save immediately — no need to click Save Configuration.
|
||||
</p>
|
||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %}
|
||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager', 'google-oauth'] %}
|
||||
{# Render widget container #}
|
||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||
<script>
|
||||
@@ -893,6 +924,12 @@
|
||||
<p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button type="button"
|
||||
onclick="window.previewPluginNow('{{ plugin.id }}')"
|
||||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm rounded-md"
|
||||
title="Run this plugin on the display for 60 seconds and open the live preview">
|
||||
<i class="fas fa-play mr-1"></i>Preview on display
|
||||
</button>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox"
|
||||
id="plugin-enabled-{{ plugin.id }}"
|
||||
@@ -1005,13 +1042,53 @@
|
||||
{# Use property order if defined, otherwise use natural order #}
|
||||
{# Skip 'enabled' field - it's handled by the header toggle #}
|
||||
{% set property_order = schema['x-propertyOrder'] if 'x-propertyOrder' in schema else schema.properties.keys()|list %}
|
||||
{# Flat (non-object) properties flagged "x-advanced": true are
|
||||
grouped into one collapsed "Advanced Settings" section after
|
||||
the basic fields. Object-type properties already render as
|
||||
their own collapsible sections, so the flag is ignored for
|
||||
them. Schemas without the flag render exactly as before. #}
|
||||
{% set tiers = namespace(basic=[], advanced=[]) %}
|
||||
{% for key in property_order %}
|
||||
{% if key in schema.properties and key != 'enabled' %}
|
||||
{% set prop = schema.properties[key] %}
|
||||
{% set value = config[key] if key in config else none %}
|
||||
{{ render_field(key, prop, value, '', plugin.id) }}
|
||||
{% set is_object = prop.type is defined and 'object' in prop.type %}
|
||||
{% if prop.get('x-advanced') and not is_object %}
|
||||
{% set tiers.advanced = tiers.advanced + [key] %}
|
||||
{% else %}
|
||||
{% set tiers.basic = tiers.basic + [key] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for key in tiers.basic %}
|
||||
{% set prop = schema.properties[key] %}
|
||||
{% set value = config[key] if key in config else none %}
|
||||
{{ render_field(key, prop, value, '', plugin.id) }}
|
||||
{% endfor %}
|
||||
{% if tiers.advanced %}
|
||||
{% set adv_section_id = (plugin.id ~ '-section-advanced-settings')|replace('.', '-')|replace('_', '-') %}
|
||||
<div class="nested-section border border-gray-300 rounded-lg mb-4">
|
||||
<button type="button"
|
||||
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
|
||||
aria-controls="{{ adv_section_id }}"
|
||||
aria-expanded="false"
|
||||
onclick="toggleSection('{{ adv_section_id }}')">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900">
|
||||
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Settings ({{ tiers.advanced|length }})
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 mt-1">Optional fine-tuning — the defaults work for most setups.</p>
|
||||
</div>
|
||||
<i id="{{ adv_section_id }}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
||||
</button>
|
||||
<div id="{{ adv_section_id }}" class="nested-content bg-gray-50 px-4 py-4 space-y-3 hidden" style="display: none;">
|
||||
{% for key in tiers.advanced %}
|
||||
{% set prop = schema.properties[key] %}
|
||||
{% set value = config[key] if key in config else none %}
|
||||
{{ render_field(key, prop, value, '', plugin.id) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# No schema - render simple form from config #}
|
||||
{% if config %}
|
||||
|
||||
@@ -466,65 +466,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Configuration Modal -->
|
||||
<div id="plugin-config-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
|
||||
<div class="modal-content p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 id="plugin-config-title" class="text-lg font-semibold">Plugin Configuration</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center bg-gray-100 rounded-lg p-1">
|
||||
<button id="view-toggle-form" class="view-toggle-btn active px-3 py-1 rounded text-sm font-medium transition-colors" data-view="form">
|
||||
<i class="fas fa-list mr-1"></i>Form
|
||||
</button>
|
||||
<button id="view-toggle-json" class="view-toggle-btn px-3 py-1 rounded text-sm font-medium transition-colors" data-view="json">
|
||||
<i class="fas fa-code mr-1"></i>JSON
|
||||
</button>
|
||||
</div>
|
||||
<!-- Reset Button -->
|
||||
<button id="reset-to-defaults-btn" class="px-3 py-1 text-sm bg-yellow-500 hover:bg-yellow-600 text-white rounded transition-colors" title="Reset to defaults">
|
||||
<i class="fas fa-undo mr-1"></i>Reset
|
||||
</button>
|
||||
<button id="close-plugin-config" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Validation Errors Display -->
|
||||
<div id="plugin-config-validation-errors" class="hidden mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-circle text-red-600 mt-0.5 mr-2"></i>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-red-800 mb-2">Configuration Validation Errors</p>
|
||||
<ul id="validation-errors-list" class="text-sm text-red-700 list-disc list-inside space-y-1"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Form View -->
|
||||
<div id="plugin-config-form-view" class="plugin-config-view">
|
||||
<div id="plugin-config-content">
|
||||
<!-- Plugin config form will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- JSON Editor View -->
|
||||
<div id="plugin-config-json-view" class="plugin-config-view hidden">
|
||||
<div class="mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Configuration JSON</label>
|
||||
<textarea id="plugin-config-json-editor" class="w-full border border-gray-300 rounded-md font-mono text-sm" rows="20"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2 pt-2 border-t border-gray-200">
|
||||
<button type="button" onclick="closePluginConfigModal()" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" id="save-json-config-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
||||
<i class="fas fa-save mr-2"></i>Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- On-Demand Modal moved to base.html so it's always available -->
|
||||
|
||||
<style>
|
||||
|
||||
@@ -31,6 +31,24 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Switch branch -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">Branch</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Choose which branch this pi follows. Switching attaches tracking, so Pull Latest works afterwards.</p>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<select id="branch-select" class="text-sm border border-gray-300 rounded-md px-2 py-2 bg-white max-w-[14rem]">
|
||||
<option value="">Loading branches…</option>
|
||||
</select>
|
||||
<button id="btn-checkout-branch" onclick="checkoutBranch(false)"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-code-branch mr-2"></i>Switch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="result-checkout-branch" class="hidden"></div>
|
||||
|
||||
<!-- Pull latest -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -467,6 +485,14 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (d.upstream) {
|
||||
html += `<p class="text-xs text-gray-500 mt-1"><i class="fas fa-link mr-1"></i>tracking <span class="font-mono">${escHtml(d.upstream)}</span></p>`;
|
||||
} else if (d.can_pull) {
|
||||
html += `<p class="text-xs text-blue-700 mt-1"><i class="fas fa-info-circle mr-1"></i>No upstream set; Pull Latest will use <span class="font-mono">origin/${escHtml(d.branch || '')}</span> and set it.</p>`;
|
||||
} else {
|
||||
html += `<p class="text-xs text-amber-700 mt-1"><i class="fas fa-triangle-exclamation mr-1"></i>No upstream and no matching branch on origin — Pull Latest cannot run. Switch to a branch that exists on the remote.</p>`;
|
||||
}
|
||||
|
||||
if (d.remote_url) {
|
||||
html += `<p class="text-xs text-gray-400 mt-1"><i class="fas fa-cloud mr-1"></i>${escHtml(d.remote_url)}</p>`;
|
||||
}
|
||||
@@ -479,6 +505,84 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── branch picker ─────────────────────────────────────────────────────
|
||||
// A pi can end up on a branch with no tracking information (checked out by
|
||||
// name, restored from a backup), where `git pull` refuses to run. Being
|
||||
// able to see and change the branch from here beats needing SSH.
|
||||
|
||||
function loadBranches() {
|
||||
const sel = document.getElementById('branch-select');
|
||||
if (!sel) return;
|
||||
|
||||
fetch('/api/v3/system/git-branches')
|
||||
.then(r => r.ok ? r.json() : r.json().then(d => Promise.reject(d.message || `HTTP ${r.status}`)))
|
||||
.then(d => {
|
||||
if (d.status === 'error') {
|
||||
sel.innerHTML = `<option value="">${escHtml(d.message || 'unavailable')}</option>`;
|
||||
sel.disabled = true;
|
||||
return;
|
||||
}
|
||||
sel.innerHTML = '';
|
||||
const add = (name, suffix) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = name;
|
||||
o.textContent = name + (suffix || '');
|
||||
if (name === d.current) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
};
|
||||
(d.local || []).forEach(b => add(b, b === d.current ? ' (current)' : ''));
|
||||
// Remote-only branches are checked out on demand.
|
||||
(d.remote_only || []).forEach(b => add(b, ' (remote)'));
|
||||
if (!sel.options.length) add('', 'no branches found');
|
||||
})
|
||||
.catch(err => {
|
||||
sel.innerHTML = `<option value="">${escHtml(String(err))}</option>`;
|
||||
sel.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
window.checkoutBranch = function(stash) {
|
||||
const sel = document.getElementById('branch-select');
|
||||
const branch = sel && sel.value;
|
||||
if (!branch) return;
|
||||
|
||||
setBusy('btn-checkout-branch', true);
|
||||
const el = document.getElementById('result-checkout-branch');
|
||||
if (el) el.classList.add('hidden');
|
||||
|
||||
fetch('/api/v3/system/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'checkout_branch', branch: branch, stash: !!stash})
|
||||
})
|
||||
.then(r => r.json().catch(() => ({status: 'error', message: `HTTP ${r.status}`})))
|
||||
.then(d => {
|
||||
const ok = d.status === 'success';
|
||||
// Show git's own list of blocking files, then offer the single
|
||||
// action that clears it. Stashing is never done unasked.
|
||||
showResult('result-checkout-branch', ok, d.message || '', d.detail || '');
|
||||
if (!ok && d.can_retry_with_stash && el) {
|
||||
const retry = document.createElement('div');
|
||||
retry.className = 'mt-2 flex items-center gap-2';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'text-xs text-gray-700';
|
||||
label.textContent = 'Stash these changes and switch anyway?';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'inline-flex items-center px-2 py-1 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50';
|
||||
btn.textContent = 'Stash and switch';
|
||||
btn.onclick = function() { window.checkoutBranch(true); };
|
||||
retry.appendChild(label);
|
||||
retry.appendChild(btn);
|
||||
el.appendChild(retry);
|
||||
}
|
||||
// Both panels describe the checkout, so refresh them together.
|
||||
loadGitInfo();
|
||||
loadBranches();
|
||||
})
|
||||
.catch(err => showResult('result-checkout-branch', false, String(err)))
|
||||
.finally(() => setBusy('btn-checkout-branch', false));
|
||||
};
|
||||
|
||||
// ── power supply diagnostics panel ────────────────────────────────────────
|
||||
// Reuses the same SSE stream (window.statsSource, set up in base.html)
|
||||
// that already drives the header badge/banner and Overview card, instead
|
||||
@@ -810,6 +914,7 @@
|
||||
|
||||
// Load on first render; HTMX will have already swapped us in by this point.
|
||||
loadGitInfo();
|
||||
loadBranches();
|
||||
|
||||
// Plugin health: initial load + periodic refresh. Guard against duplicate
|
||||
// timers if this partial is re-swapped in by HTMX; the handler re-resolves
|
||||
|
||||
Reference in New Issue
Block a user