mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-06 03:08:05 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a93b2c89e | ||
|
|
14df879e31 | ||
|
|
99ea157fb2 | ||
|
|
35bc299162 | ||
|
|
cf84a76fb2 | ||
|
|
30e1837535 | ||
|
|
f4301f2675 | ||
|
|
560513d435 | ||
|
|
f2c1d6f80c | ||
|
|
b54d56a276 |
@@ -2851,7 +2851,7 @@ class DisplayController:
|
|||||||
# configured rotation slot before resyncing the index.
|
# configured rotation slot before resyncing the index.
|
||||||
self._apply_plugin_rotation_order()
|
self._apply_plugin_rotation_order()
|
||||||
self._resync_mode_index_after_change(previous_mode)
|
self._resync_mode_index_after_change(previous_mode)
|
||||||
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
|
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)",
|
||||||
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -77,6 +77,13 @@ def client():
|
|||||||
|
|
||||||
from web_interface.blueprints import pages_v3 as pv
|
from web_interface.blueprints import pages_v3 as pv
|
||||||
|
|
||||||
|
# pages_v3 is a module-level Blueprint singleton shared by the whole test
|
||||||
|
# process (test_web_settings_ui.py mutates the same attributes) - save
|
||||||
|
# the originals and restore them on teardown so this fixture can't leak
|
||||||
|
# its mocks into tests that run afterward.
|
||||||
|
original_config_manager = getattr(pv.pages_v3, "config_manager", None)
|
||||||
|
original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None)
|
||||||
|
|
||||||
mock_cm = MagicMock()
|
mock_cm = MagicMock()
|
||||||
mock_cm.load_config.return_value = SMOKE_CONFIG
|
mock_cm.load_config.return_value = SMOKE_CONFIG
|
||||||
mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG
|
mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG
|
||||||
@@ -99,7 +106,11 @@ def client():
|
|||||||
# /v3 kept as a working legacy alias.
|
# /v3 kept as a working legacy alias.
|
||||||
app.register_blueprint(pv.pages_v3, url_prefix="")
|
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||||
app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy")
|
app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy")
|
||||||
return app.test_client()
|
try:
|
||||||
|
yield app.test_client()
|
||||||
|
finally:
|
||||||
|
pv.pages_v3.config_manager = original_config_manager
|
||||||
|
pv.pages_v3.plugin_manager = original_plugin_manager
|
||||||
|
|
||||||
|
|
||||||
# (path, [markers that must appear in the body])
|
# (path, [markers that must appear in the body])
|
||||||
|
|||||||
@@ -978,8 +978,14 @@ def save_main_config():
|
|||||||
current_config['display'] = {}
|
current_config['display'] = {}
|
||||||
current_config['display']['plugin_rotation_order'] = parsed
|
current_config['display']['plugin_rotation_order'] = parsed
|
||||||
|
|
||||||
# Handle display durations
|
# Handle display durations. Popped from `data` (not just read) so
|
||||||
duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']]
|
# they can never also fall through to the generic "remaining keys"
|
||||||
|
# merge near the end of this function, which would otherwise write
|
||||||
|
# them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30
|
||||||
|
# sitting at config root alongside the correct
|
||||||
|
# display.display_durations.clock_duration).
|
||||||
|
duration_fields = [k for k in list(data.keys())
|
||||||
|
if k.endswith('_duration') or k in ('default_duration', 'transition_duration')]
|
||||||
if duration_fields:
|
if duration_fields:
|
||||||
if 'display' not in current_config:
|
if 'display' not in current_config:
|
||||||
current_config['display'] = {}
|
current_config['display'] = {}
|
||||||
@@ -987,13 +993,19 @@ def save_main_config():
|
|||||||
current_config['display']['display_durations'] = {}
|
current_config['display']['display_durations'] = {}
|
||||||
|
|
||||||
for field in duration_fields:
|
for field in duration_fields:
|
||||||
if field in data:
|
raw_value = data.pop(field)
|
||||||
current_config['display']['display_durations'][field] = int(data[field])
|
try:
|
||||||
|
int_value = int(raw_value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': f"Invalid duration for {field}: must be an integer"}), 400
|
||||||
|
current_config['display']['display_durations'][field] = int_value
|
||||||
|
|
||||||
# Per-mode durations from the Rotation & Durations page, posted as
|
# Per-mode durations from the Rotation & Durations page, posted as
|
||||||
# duration__<mode_key> (mode keys are arbitrary plugin mode names, so
|
# duration__<mode_key> (mode keys are arbitrary plugin mode names, so
|
||||||
# they can't use the suffix convention above)
|
# they can't use the suffix convention above). Same pop-and-validate
|
||||||
mode_duration_fields = [k for k in data.keys() if k.startswith('duration__')]
|
# treatment, for the same reason.
|
||||||
|
mode_duration_fields = [k for k in list(data.keys()) if k.startswith('duration__')]
|
||||||
if mode_duration_fields:
|
if mode_duration_fields:
|
||||||
if 'display' not in current_config:
|
if 'display' not in current_config:
|
||||||
current_config['display'] = {}
|
current_config['display'] = {}
|
||||||
@@ -1001,13 +1013,16 @@ def save_main_config():
|
|||||||
current_config['display']['display_durations'] = {}
|
current_config['display']['display_durations'] = {}
|
||||||
|
|
||||||
for field in mode_duration_fields:
|
for field in mode_duration_fields:
|
||||||
|
raw_value = data.pop(field)
|
||||||
mode_key = field[len('duration__'):]
|
mode_key = field[len('duration__'):]
|
||||||
if not mode_key:
|
if not mode_key:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
current_config['display']['display_durations'][mode_key] = int(data[field])
|
int_value = int(raw_value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
logger.warning("Ignoring non-integer duration for %s", mode_key)
|
return jsonify({'status': 'error',
|
||||||
|
'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400
|
||||||
|
current_config['display']['display_durations'][mode_key] = int_value
|
||||||
|
|
||||||
# Handle plugin configurations dynamically
|
# Handle plugin configurations dynamically
|
||||||
# Any key that matches a plugin ID should be saved as plugin config
|
# Any key that matches a plugin ID should be saved as plugin config
|
||||||
@@ -1719,7 +1734,12 @@ def execute_system_action():
|
|||||||
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
|
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
|
||||||
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
|
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
|
||||||
req_path = PROJECT_ROOT / rel
|
req_path = PROJECT_ROOT / rel
|
||||||
if rel in changed and req_path.exists():
|
if rel not in changed or not req_path.exists():
|
||||||
|
continue
|
||||||
|
# Each file's install is isolated: a timeout or
|
||||||
|
# OSError (e.g. the sudo wrapper/interpreter
|
||||||
|
# missing) on one file must not abort the other.
|
||||||
|
try:
|
||||||
r = _pip_install_requirements(req_path, timeout=180)
|
r = _pip_install_requirements(req_path, timeout=180)
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
dep_notes.append(f"Dependencies from {rel} updated.")
|
dep_notes.append(f"Dependencies from {rel} updated.")
|
||||||
@@ -1729,6 +1749,17 @@ def execute_system_action():
|
|||||||
"run Install Base Requirements from the Tools tab.")
|
"run Install Base Requirements from the Tools tab.")
|
||||||
logger.warning("post-update pip install failed for %s: %s",
|
logger.warning("post-update pip install failed for %s: %s",
|
||||||
rel, _truncate_output(r.stdout, r.stderr))
|
rel, _truncate_output(r.stdout, r.stderr))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
dep_notes.append(
|
||||||
|
f"Dependency install from {rel} timed out — "
|
||||||
|
"run Install Base Requirements from the Tools tab.")
|
||||||
|
logger.warning("post-update pip install timed out for %s", rel)
|
||||||
|
except OSError as install_err:
|
||||||
|
dep_notes.append(
|
||||||
|
f"Dependency install from {rel} failed — "
|
||||||
|
"run Install Base Requirements from the Tools tab.")
|
||||||
|
logger.warning("post-update pip install errored for %s: %s",
|
||||||
|
rel, install_err)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.warning("post-update dependency sync timed out")
|
logger.warning("post-update dependency sync timed out")
|
||||||
if dep_notes:
|
if dep_notes:
|
||||||
@@ -1773,10 +1804,22 @@ def execute_system_action():
|
|||||||
outputs = []
|
outputs = []
|
||||||
all_ok = True
|
all_ok = True
|
||||||
for req_file in req_files:
|
for req_file in req_files:
|
||||||
result = _pip_install_requirements(req_file, timeout=120)
|
label = req_file.relative_to(PROJECT_ROOT)
|
||||||
all_ok = all_ok and result.returncode == 0
|
# Isolate each file's install: a timeout or OSError on one
|
||||||
outputs.append(f"== {req_file.relative_to(PROJECT_ROOT)} ==\n"
|
# (e.g. requirements.txt) must not abort the rest of the
|
||||||
+ _truncate_output(result.stdout, result.stderr))
|
# loop (e.g. web_interface/requirements.txt never attempted).
|
||||||
|
try:
|
||||||
|
result = _pip_install_requirements(req_file, timeout=120)
|
||||||
|
all_ok = all_ok and result.returncode == 0
|
||||||
|
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
all_ok = False
|
||||||
|
outputs.append(f"== {label} ==\nTimed out after 120s")
|
||||||
|
logger.warning("install_base_requirements timed out for %s", label)
|
||||||
|
except OSError as install_err:
|
||||||
|
all_ok = False
|
||||||
|
outputs.append(f"== {label} ==\nFailed: {install_err}")
|
||||||
|
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success' if all_ok else 'error',
|
'status': 'success' if all_ok else 'error',
|
||||||
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
|
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
|
||||||
|
|||||||
@@ -2775,7 +2775,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.executePluginAction = function(pluginId, actionId) {
|
window.executePluginAction = function(actionId, actionIndex, pluginId) {
|
||||||
fetch(`/api/v3/plugins/action?plugin_id=${pluginId}&action_id=${actionId}`, {
|
fetch(`/api/v3/plugins/action?plugin_id=${pluginId}&action_id=${actionId}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,12 +69,11 @@
|
|||||||
errorStr.includes("reading 'insertBefore'")) {
|
errorStr.includes("reading 'insertBefore'")) {
|
||||||
// Check if it's from HTMX by looking at stack trace or error string
|
// Check if it's from HTMX by looking at stack trace or error string
|
||||||
// Also check the call stack if available
|
// Also check the call stack if available
|
||||||
const isHtmxError = errorStr.includes('htmx.org') ||
|
const isHtmxError = errorStr.includes('htmx') ||
|
||||||
errorStr.includes('htmx') ||
|
|
||||||
errorStack.includes('htmx') ||
|
errorStack.includes('htmx') ||
|
||||||
args.some(arg => {
|
args.some(arg => {
|
||||||
if (typeof arg === 'string') {
|
if (typeof arg === 'string') {
|
||||||
return arg.includes('htmx.org') || arg.includes('htmx');
|
return arg.includes('htmx');
|
||||||
}
|
}
|
||||||
// Check error objects for stack traces
|
// Check error objects for stack traces
|
||||||
if (arg && typeof arg === 'object' && arg.stack) {
|
if (arg && typeof arg === 'object' && arg.stack) {
|
||||||
@@ -162,34 +161,35 @@
|
|||||||
// Log but don't break the app
|
// Log but don't break the app
|
||||||
console.warn('HTMX swap error:', event.detail);
|
console.warn('HTMX swap error:', event.detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Execute <script> tags in swapped content ourselves, on
|
||||||
|
// htmx:afterSwap (synchronous, right after the swap) rather
|
||||||
|
// than relying on htmx's own script handling, which runs
|
||||||
|
// during its later "settle" phase (~20ms after swap, per
|
||||||
|
// htmx's defaultSettleDelay). Alpine's MutationObserver
|
||||||
|
// processes newly-inserted x-data elements synchronously
|
||||||
|
// as soon as the swap lands, which is BEFORE htmx's settle
|
||||||
|
// phase - so any partial whose x-data component function
|
||||||
|
// (e.g. wifiSetup()) is defined by an inline <script> in
|
||||||
|
// that same partial would have that script still un-run
|
||||||
|
// when Alpine evaluates x-data, permanently failing with
|
||||||
|
// "wifiSetup is not defined" (Alpine does not retry).
|
||||||
|
// Disable htmx's own native script re-execution so the
|
||||||
|
// same script doesn't also run a second time via settle.
|
||||||
|
if (typeof htmx !== 'undefined' && htmx.config) {
|
||||||
|
htmx.config.allowScriptTags = false;
|
||||||
|
}
|
||||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
if (event.detail && event.detail.target) {
|
const target = event.detail && event.detail.target;
|
||||||
try {
|
if (!target || !(target instanceof Element)) return;
|
||||||
const scripts = event.detail.target.querySelectorAll('script');
|
target.querySelectorAll('script').forEach(function(oldScript) {
|
||||||
scripts.forEach(function(oldScript) {
|
const newScript = document.createElement('script');
|
||||||
try {
|
for (const attr of oldScript.attributes) {
|
||||||
if (oldScript.textContent.trim() || oldScript.src) {
|
newScript.setAttribute(attr.name, attr.value);
|
||||||
const newScript = document.createElement('script');
|
|
||||||
if (oldScript.src) newScript.src = oldScript.src;
|
|
||||||
if (oldScript.type) newScript.type = oldScript.type;
|
|
||||||
if (oldScript.textContent) newScript.textContent = oldScript.textContent;
|
|
||||||
if (oldScript.parentNode) {
|
|
||||||
oldScript.parentNode.insertBefore(newScript, oldScript);
|
|
||||||
oldScript.parentNode.removeChild(oldScript);
|
|
||||||
} else {
|
|
||||||
// If no parent, append to head or body
|
|
||||||
(document.head || document.body).appendChild(newScript);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently ignore script execution errors
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Silently ignore errors in script processing
|
|
||||||
}
|
}
|
||||||
}
|
newScript.textContent = oldScript.textContent;
|
||||||
|
oldScript.replaceWith(newScript);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark tab containers as loaded once their content settles, so switching
|
// Mark tab containers as loaded once their content settles, so switching
|
||||||
|
|||||||
@@ -209,6 +209,7 @@
|
|||||||
const removeButton = document.createElement('button');
|
const removeButton = document.createElement('button');
|
||||||
removeButton.type = 'button';
|
removeButton.type = 'button';
|
||||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||||
|
removeButton.setAttribute('aria-label', 'Remove feed');
|
||||||
removeButton.addEventListener('click', function() {
|
removeButton.addEventListener('click', function() {
|
||||||
window.removeCustomFeedRow(this);
|
window.removeCustomFeedRow(this);
|
||||||
});
|
});
|
||||||
@@ -333,6 +334,7 @@
|
|||||||
const removeButton = document.createElement('button');
|
const removeButton = document.createElement('button');
|
||||||
removeButton.type = 'button';
|
removeButton.type = 'button';
|
||||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||||
|
removeButton.setAttribute('aria-label', 'Remove feed');
|
||||||
removeButton.addEventListener('click', function() {
|
removeButton.addEventListener('click', function() {
|
||||||
window.removeCustomFeedRow(this);
|
window.removeCustomFeedRow(this);
|
||||||
});
|
});
|
||||||
@@ -404,7 +406,10 @@
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
// Backend contract (api_v3.upload_plugin_asset): field must be named
|
||||||
|
// "files" (request.files.getlist('files')), and the response carries
|
||||||
|
// results in a top-level "uploaded_files" key, not nested under "data".
|
||||||
|
formData.append('files', file);
|
||||||
formData.append('plugin_id', pluginId);
|
formData.append('plugin_id', pluginId);
|
||||||
|
|
||||||
fetch('/api/v3/plugins/assets/upload', {
|
fetch('/api/v3/plugins/assets/upload', {
|
||||||
@@ -421,8 +426,8 @@
|
|||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
|
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
|
||||||
const uploadedFile = data.data.files[0];
|
const uploadedFile = data.uploaded_files[0];
|
||||||
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
|
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
|
||||||
if (row) {
|
if (row) {
|
||||||
const logoCell = row.querySelector('td:nth-child(3)');
|
const logoCell = row.querySelector('td:nth-child(3)');
|
||||||
@@ -495,8 +500,6 @@
|
|||||||
// Append container to logoCell
|
// Append container to logoCell
|
||||||
logoCell.appendChild(container);
|
logoCell.appendChild(container);
|
||||||
}
|
}
|
||||||
// Allow re-uploading the same file
|
|
||||||
event.target.value = '';
|
|
||||||
} else {
|
} else {
|
||||||
const notifyFn = window.showNotification || alert;
|
const notifyFn = window.showNotification || alert;
|
||||||
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
|
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
|
||||||
@@ -506,6 +509,12 @@
|
|||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
const notifyFn = window.showNotification || alert;
|
const notifyFn = window.showNotification || alert;
|
||||||
notifyFn('Upload failed: ' + error.message, 'error');
|
notifyFn('Upload failed: ' + error.message, 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Reset regardless of outcome, so the same file can be re-selected
|
||||||
|
// to retry after a failure (browsers won't fire "change" again
|
||||||
|
// for an input that still holds that exact file).
|
||||||
|
event.target.value = '';
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -667,7 +667,7 @@ window.handleGitHubPluginInstall = function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!repoUrl.includes('github.com')) {
|
if (!isGithubUrl(repoUrl)) {
|
||||||
if (statusDiv) {
|
if (statusDiv) {
|
||||||
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
||||||
}
|
}
|
||||||
@@ -3334,16 +3334,28 @@ window.uninstallPlugin = function(pluginId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollOperationStatus(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) {
|
function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
|
||||||
|
const maxAttempts = options.maxAttempts || 60;
|
||||||
|
const attempt = options.attempt || 0;
|
||||||
|
const onComplete = options.onComplete || (() => handleUninstallSuccess(pluginId));
|
||||||
|
const onFailed = options.onFailed || ((errorMsg) => {
|
||||||
|
showNotification(errorMsg || `Operation failed for ${pluginName}`, 'error');
|
||||||
|
setTimeout(() => loadInstalledPlugins(), 1000);
|
||||||
|
});
|
||||||
|
const onTimeout = options.onTimeout || (() => {
|
||||||
|
showNotification(`Operation timed out for ${pluginName}`, 'error');
|
||||||
|
setTimeout(() => loadInstalledPlugins(), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
if (attempt >= maxAttempts) {
|
if (attempt >= maxAttempts) {
|
||||||
showNotification(`Uninstall operation timed out for ${pluginName}`, 'error');
|
onTimeout();
|
||||||
// Refresh plugin list to see actual state
|
|
||||||
setTimeout(() => {
|
|
||||||
loadInstalledPlugins();
|
|
||||||
}, 1000);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pollAgain = () => setTimeout(() => {
|
||||||
|
pollOperationStatus(operationId, pluginId, pluginName, { ...options, attempt: attempt + 1 });
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
fetch(`/api/v3/plugins/operation/${operationId}`)
|
fetch(`/api/v3/plugins/operation/${operationId}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -3352,32 +3364,16 @@ function pollOperationStatus(operationId, pluginId, pluginName, maxAttempts = 60
|
|||||||
const status = operation.status;
|
const status = operation.status;
|
||||||
|
|
||||||
if (status === 'completed') {
|
if (status === 'completed') {
|
||||||
// Operation completed successfully
|
onComplete();
|
||||||
handleUninstallSuccess(pluginId);
|
|
||||||
} else if (status === 'failed') {
|
} else if (status === 'failed') {
|
||||||
// Operation failed
|
onFailed(operation.error || operation.message);
|
||||||
const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`;
|
|
||||||
showNotification(errorMsg, 'error');
|
|
||||||
// Refresh plugin list to see actual state
|
|
||||||
setTimeout(() => {
|
|
||||||
loadInstalledPlugins();
|
|
||||||
}, 1000);
|
|
||||||
} else if (status === 'pending' || status === 'in_progress') {
|
|
||||||
// Still in progress, poll again
|
|
||||||
setTimeout(() => {
|
|
||||||
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
||||||
}, 1000); // Poll every second
|
|
||||||
} else {
|
} else {
|
||||||
// Unknown status, poll again
|
// 'pending', 'in_progress', or unknown - poll again
|
||||||
setTimeout(() => {
|
pollAgain();
|
||||||
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Error getting operation status, try again
|
// Error getting operation status, try again
|
||||||
setTimeout(() => {
|
pollAgain();
|
||||||
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -3883,6 +3879,33 @@ window.installPlugin = function(pluginId, branch = null) {
|
|||||||
requestBody.branch = branch;
|
requestBody.branch = branch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enableAfterInstall() {
|
||||||
|
// Enable immediately so install -> enable is one step; only nudge
|
||||||
|
// for a restart once enablement actually succeeded (persistent
|
||||||
|
// toast; duration 0 = stays until dismissed).
|
||||||
|
Promise.resolve(window.togglePlugin(pluginId, true)).then(toggleResult => {
|
||||||
|
if (toggleResult && toggleResult.status === 'success') {
|
||||||
|
showNotification(
|
||||||
|
`${pluginId} installed and enabled — restart the display to show it`,
|
||||||
|
{
|
||||||
|
type: 'success',
|
||||||
|
duration: 0,
|
||||||
|
actionLabel: 'Restart Now',
|
||||||
|
onAction: () => restartDisplay()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(
|
||||||
|
`${pluginId} installed, but enabling it failed — use its toggle in the plugin list`,
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Refresh installed plugins list, then re-render store to update badges
|
||||||
|
loadInstalledPlugins();
|
||||||
|
setTimeout(() => applyStoreFiltersAndSort(true), 500);
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/api/v3/plugins/install', {
|
fetch('/api/v3/plugins/install', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -3891,31 +3914,23 @@ window.installPlugin = function(pluginId, branch = null) {
|
|||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
showNotification(data.message, data.status);
|
showNotification(data.message, data.status);
|
||||||
if (data.status === 'success') {
|
if (data.status !== 'success') return;
|
||||||
// Enable immediately so install -> enable is one step; only nudge
|
|
||||||
// for a restart once enablement actually succeeded (persistent
|
if (data.data && data.data.operation_id) {
|
||||||
// toast; duration 0 = stays until dismissed).
|
// Install runs async via the operation queue - this response only
|
||||||
Promise.resolve(window.togglePlugin(pluginId, true)).then(toggleResult => {
|
// means "queued", not "installed". Enabling immediately here would
|
||||||
if (toggleResult && toggleResult.status === 'success') {
|
// 404 with "Plugin not found" against the toggle endpoint, since
|
||||||
showNotification(
|
// the plugin manager hasn't discovered the new plugin yet (seen
|
||||||
`${pluginId} installed and enabled — restart the display to show it`,
|
// live: "installation queued" followed immediately by a failed
|
||||||
{
|
// enable). Wait for the operation to actually finish first.
|
||||||
type: 'success',
|
pollOperationStatus(data.data.operation_id, pluginId, pluginId, {
|
||||||
duration: 0,
|
onComplete: enableAfterInstall,
|
||||||
actionLabel: 'Restart Now',
|
onFailed: (errorMsg) => showNotification(errorMsg || `Failed to install ${pluginId}`, 'error'),
|
||||||
onAction: () => restartDisplay()
|
onTimeout: () => showNotification(`Install operation timed out for ${pluginId}`, 'error')
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showNotification(
|
|
||||||
`${pluginId} installed, but enabling it failed — use its toggle in the plugin list`,
|
|
||||||
'warning'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// Refresh installed plugins list, then re-render store to update badges
|
} else {
|
||||||
loadInstalledPlugins();
|
// No operation queue configured - install already completed synchronously.
|
||||||
setTimeout(() => applyStoreFiltersAndSort(true), 500);
|
enableAfterInstall();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -4022,9 +4037,9 @@ function renderSavedRepositories(repositories) {
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<i class="fas ${repoType === 'registry' ? 'fa-folder-open' : 'fa-code-branch'} text-gray-400 text-xs"></i>
|
<i class="fas ${repoType === 'registry' ? 'fa-folder-open' : 'fa-code-branch'} text-gray-400 text-xs"></i>
|
||||||
<span class="text-sm font-medium text-gray-900 truncate" title="${repoUrl}">${escapeHtml(repoName)}</span>
|
<span class="text-sm font-medium text-gray-900 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoName)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 truncate" title="${repoUrl}">${escapeHtml(repoUrl)}</p>
|
<p class="text-xs text-gray-500 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoUrl)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository">
|
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
@@ -4107,7 +4122,7 @@ function attachInstallButtonHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!repoUrl.includes('github.com')) {
|
if (!isGithubUrl(repoUrl)) {
|
||||||
if (pluginStatusDiv) {
|
if (pluginStatusDiv) {
|
||||||
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
||||||
}
|
}
|
||||||
@@ -4280,7 +4295,7 @@ function setupGitHubInstallHandlers() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!repoUrl.includes('github.com')) {
|
if (!isGithubUrl(repoUrl)) {
|
||||||
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4336,7 +4351,7 @@ function setupGitHubInstallHandlers() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!repoUrl.includes('github.com')) {
|
if (!isGithubUrl(repoUrl)) {
|
||||||
showError('Please enter a valid GitHub URL');
|
showError('Please enter a valid GitHub URL');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4480,6 +4495,19 @@ function showError(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Validate that a URL's actual host is github.com (not just a substring
|
||||||
|
// match, which 'evil.com/github.com' or 'github.com.evil.com' would pass).
|
||||||
|
// This is only a UX nicety pointing users at a valid URL - the server does
|
||||||
|
// its own proper hostname validation before actually acting on the URL.
|
||||||
|
function isGithubUrl(url) {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname.toLowerCase();
|
||||||
|
return hostname === 'github.com' || hostname === 'www.github.com';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Utility function to escape HTML
|
// Utility function to escape HTML
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
|
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>)
|
(<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).
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
name="rows"
|
name="rows"
|
||||||
value="{{ main_config.display.hardware.rows or 32 }}"
|
value="{{ main_config.display.hardware.rows or 32 }}"
|
||||||
min="1"
|
min="1"
|
||||||
max="64"
|
max="128"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
<h4 class="font-semibold text-gray-900">
|
<h4 class="font-semibold text-gray-900">
|
||||||
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware & Display Options (15)
|
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware & Display Options (15)
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-sm text-gray-600 mt-1">Multiplexing, panel variants, PWM tuning, and display options Multiplexing, panel variants, and PWM tuning — the defaults work for standard HUB75 panels.mdash; the defaults work for standard HUB75 panels.</p>
|
<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>
|
</div>
|
||||||
<i id="display-section-advanced-hardware-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
<i id="display-section-advanced-hardware-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -153,7 +153,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
||||||
var data = appEl && appEl._x_dataStack && appEl._x_dataStack[0];
|
// 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) {
|
if (data) {
|
||||||
data.activeTab = btn.dataset.tab;
|
data.activeTab = btn.dataset.tab;
|
||||||
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user