mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
merge: bring the element-style work onto main's api_v3 package split
main moved 16 commits while this branch was open, and one of them split api_v3.py into a package. A rebase would have conflicted on that file in each of the 15 commits that touch it; merging resolves it once. Reapplied into the package, unchanged in substance: - _schema_type_is / _schema_allows_null and the nullable-field handling (api_v3/__init__.py, where the form parser now lives) - the indexed-array recombiner's union-type fix and its all-blank-means-null case (api_v3/plugins.py) - scalable / native_size on the font catalog, and the removal of the three font-override stubs (api_v3/fonts.py) Two places took main's version rather than mine, deliberately: - pages_v3 now reads the schema through SchemaManager *and* keeps resolve_under for the raw-read fallback, so the containment guard main added in the CodeQL triage survives. - serve_plugin_widget moves off os.path.basename onto safe_path_component and resolve_under. main dropped `import os` from pages_v3 in favour of those helpers, and they reject a bad component rather than mangling it into a valid-looking one. The url-map snapshot loses exactly the three font-override routes. Edited rather than regenerated: that test exists to catch a route lost by accident during a move, and regenerating it wholesale is how such a loss would be papered over. Verified against a fresh run of current main: same failure set, 4692 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -152,7 +152,8 @@ function displayCacheFiles(data) {
|
||||
<span class="text-sm text-gray-600">${escapeHtml(modifiedStr)}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick="deleteCacheFile('${escapeHtml(cacheFile.key)}')"
|
||||
<button onclick="deleteCacheFile(this.dataset.cacheKey)"
|
||||
data-cache-key="${escapeHtml(cacheFile.key)}"
|
||||
class="text-red-600 hover:text-red-900 px-3 py-1 rounded hover:bg-red-50 transition-colors"
|
||||
title="Delete cache file">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
@@ -220,10 +221,14 @@ function showError(message) {
|
||||
}
|
||||
|
||||
// Utility function to escape HTML
|
||||
// Quotes too: the result is interpolated into quoted attribute values, and the
|
||||
// textContent/innerHTML round-trip only escapes &, < and >.
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Make deleteCacheFile available globally for onclick handlers
|
||||
|
||||
@@ -711,11 +711,15 @@ function showError(message) {
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to escape HTML
|
||||
// Utility function to escape HTML. Quotes too: the result is interpolated into
|
||||
// quoted attribute values (option value=, title=), and the
|
||||
// textContent/innerHTML round-trip only escapes &, < and >.
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function refreshCurrentPluginStatus() {
|
||||
|
||||
@@ -30,10 +30,33 @@
|
||||
if (!d.unresolved || d.unresolved.length === 0) return;
|
||||
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
||||
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
||||
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
|
||||
document.getElementById('reconciliation-banner-text').textContent =
|
||||
'Stale plugin config entries found: ' + ids +
|
||||
'. Remove them from config.json or reinstall via the Plugin Store.';
|
||||
// These are opposite problems and need opposite advice. Lumping
|
||||
// them together told users to delete config.json entries for
|
||||
// plugins that were installed and correctly configured.
|
||||
function idsOfType(t) {
|
||||
return d.unresolved.filter(function (i) { return i.type === t; })
|
||||
.map(function (i) { return i.plugin_id; });
|
||||
}
|
||||
var notConfigured = idsOfType('plugin_missing_in_config');
|
||||
var notInstalled = idsOfType('plugin_missing_on_disk');
|
||||
var other = d.unresolved.filter(function (i) {
|
||||
return i.type !== 'plugin_missing_in_config'
|
||||
&& i.type !== 'plugin_missing_on_disk';
|
||||
}).map(function (i) { return i.plugin_id; });
|
||||
|
||||
var parts = [];
|
||||
if (notConfigured.length) {
|
||||
parts.push('Installed but missing from config: ' + notConfigured.join(', ')
|
||||
+ '. Configure them in the Plugin Store - do not remove anything from config.json.');
|
||||
}
|
||||
if (notInstalled.length) {
|
||||
parts.push('In config but not installed: ' + notInstalled.join(', ')
|
||||
+ '. Reinstall via the Plugin Store, or remove these entries from config.json.');
|
||||
}
|
||||
if (other.length) {
|
||||
parts.push('Needs attention: ' + other.join(', ') + '.');
|
||||
}
|
||||
document.getElementById('reconciliation-banner-text').textContent = parts.join(' ');
|
||||
var banner = document.getElementById('reconciliation-banner');
|
||||
banner.dataset.dismissKey = key;
|
||||
banner.style.setProperty('display', 'flex', 'important');
|
||||
|
||||
@@ -233,6 +233,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MQTT Bridge -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">MQTT Bridge</h2>
|
||||
<span id="mqtt-bridge-state" class="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-600">checking…</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Exposes the display to Home Assistant over MQTT — force a mode on demand, set
|
||||
brightness, toggle power. Runs as <code class="bg-gray-100 px-1 rounded">ledmatrix-mqtt-bridge.service</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="mqtt-bridge-body" class="space-y-4">
|
||||
<p class="text-sm text-gray-500">Loading settings…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pixlet Config Editor -->
|
||||
<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">Pixlet Config Editor</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Opens a Starlark app in Pixlet's own config form, which runs the app for real —
|
||||
so dropdowns that build their options at runtime work, and you see the render update
|
||||
as you type.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
The display stops while a session is open, and the editor has no password — the
|
||||
same footing as this page. Sessions end by themselves after 30 minutes, so the
|
||||
panel is never left dark.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="pixlet-editor-body" class="space-y-4">
|
||||
<p class="text-sm text-gray-500">Loading apps…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Power -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
@@ -962,5 +1002,348 @@
|
||||
|
||||
// WiFi radio current state.
|
||||
window.loadWifiRadio();
|
||||
|
||||
// ── MQTT bridge ───────────────────────────────────────────────────────────
|
||||
|
||||
const MQTT_BRIDGE_URL = '/api/v3/integrations/mqtt-bridge';
|
||||
|
||||
function mqttBadge(service, configExists) {
|
||||
if (!service.installed) return ['bg-gray-100 text-gray-600', 'not installed'];
|
||||
if (service.active) return ['bg-green-100 text-green-700', 'running'];
|
||||
if (!configExists) return ['bg-amber-100 text-amber-700', 'not configured'];
|
||||
return ['bg-amber-100 text-amber-700', 'stopped'];
|
||||
}
|
||||
|
||||
function renderMqttBridge(data) {
|
||||
const badge = document.getElementById('mqtt-bridge-state');
|
||||
const [cls, label] = mqttBadge(data.service, data.config_exists);
|
||||
if (badge) {
|
||||
badge.className = 'text-xs px-2 py-1 rounded-full ' + cls;
|
||||
badge.textContent = label;
|
||||
}
|
||||
|
||||
const c = data.config || {};
|
||||
const field = (id, label, value, type, extra) => `
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700">${escHtml(label)}</span>
|
||||
<input id="${id}" type="${type || 'text'}" value="${value === null || value === undefined ? '' : escHtml(String(value))}"
|
||||
${extra || ''}
|
||||
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
|
||||
</label>`;
|
||||
|
||||
document.getElementById('mqtt-bridge-body').innerHTML = `
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
${field('mqtt-host', 'Broker host', c.mqtt_host)}
|
||||
${field('mqtt-port', 'Port', c.mqtt_port, 'number', 'min="1" max="65535"')}
|
||||
${field('mqtt-username', 'Username (optional)', c.mqtt_username)}
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700">Password</span>
|
||||
<input id="mqtt-password" type="password" value=""
|
||||
placeholder="${data.password_set ? 'unchanged — leave blank to keep' : 'none set'}"
|
||||
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
|
||||
<span class="text-xs text-gray-500">
|
||||
Never sent back to the browser.
|
||||
${data.password_set ? '<button type="button" id="mqtt-clear-password" class="text-red-600 hover:underline ml-1">Clear it</button>' : ''}
|
||||
</span>
|
||||
</label>
|
||||
${field('mqtt-topic', 'Command topic', c.mqtt_topic)}
|
||||
${field('mqtt-client-id', 'Client ID', c.mqtt_client_id)}
|
||||
${field('mqtt-api-base', 'LEDMatrix API base', c.ledmatrix_api_base)}
|
||||
${field('mqtt-timeout', 'Request timeout (s)', c.request_timeout, 'number', 'min="1" max="300"')}
|
||||
${field('mqtt-duration', 'On-demand duration (s, blank = default)', c.on_demand_duration, 'number', 'min="1" max="86400"')}
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700">Log level</span>
|
||||
<select id="mqtt-log-level" class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md bg-white">
|
||||
${['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'].map(l =>
|
||||
`<option value="${l}" ${c.log_level === l ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input id="mqtt-tls" type="checkbox" ${c.mqtt_tls ? 'checked' : ''}
|
||||
class="rounded border-gray-300">
|
||||
Use TLS
|
||||
<span class="text-xs text-gray-500">(a password without TLS crosses the network in the clear)</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 pt-2">
|
||||
<p class="text-xs text-gray-500">
|
||||
Saved to <code class="bg-gray-100 px-1 rounded">${escHtml(data.config_path)}</code>.
|
||||
Any field can also be set as
|
||||
<code class="bg-gray-100 px-1 rounded">${escHtml(data.env_override_prefix)}<KEY></code>,
|
||||
which wins over the file.
|
||||
</p>
|
||||
<button id="btn-mqtt-save" onclick="saveMqttBridge()"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||
<i class="fas fa-save mr-2"></i>Save settings
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-mqtt-save" class="hidden"></div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 pt-4 border-t border-gray-100">
|
||||
${data.service.installed ? `
|
||||
<button id="btn-mqtt-restart" onclick="toolsAction('mqtt_bridge_restart','btn-mqtt-restart','result-mqtt-service')"
|
||||
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-sync-alt mr-2"></i>Restart
|
||||
</button>
|
||||
<button id="btn-mqtt-startstop"
|
||||
onclick="toolsAction('${data.service.active ? 'mqtt_bridge_stop' : 'mqtt_bridge_start'}','btn-mqtt-startstop','result-mqtt-service')"
|
||||
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-${data.service.active ? 'stop' : 'play'} mr-2"></i>${data.service.active ? 'Stop' : 'Start'}
|
||||
</button>` : `
|
||||
<button id="btn-mqtt-install" onclick="installMqttBridge()"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||
<i class="fas fa-download mr-2"></i>Install & start bridge
|
||||
</button>
|
||||
<span class="text-xs text-gray-500">Installs dependencies, enables the unit and starts it.</span>`}
|
||||
</div>
|
||||
<div id="result-mqtt-service" class="hidden"></div>
|
||||
`;
|
||||
|
||||
const clearBtn = document.getElementById('mqtt-clear-password');
|
||||
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
|
||||
}
|
||||
|
||||
window.loadMqttBridge = function() {
|
||||
fetch(MQTT_BRIDGE_URL)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.status !== 'success') throw new Error(d.message || 'Failed');
|
||||
renderMqttBridge(d.data);
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('mqtt-bridge-body').innerHTML =
|
||||
`<p class="text-sm text-red-600">Could not load bridge settings: ${escHtml(err.message)}</p>`;
|
||||
});
|
||||
};
|
||||
|
||||
function mqttBody() {
|
||||
const val = id => (document.getElementById(id) || {}).value;
|
||||
const body = {
|
||||
mqtt_host: val('mqtt-host'),
|
||||
mqtt_port: val('mqtt-port'),
|
||||
mqtt_username: val('mqtt-username'),
|
||||
mqtt_topic: val('mqtt-topic'),
|
||||
mqtt_client_id: val('mqtt-client-id'),
|
||||
ledmatrix_api_base: val('mqtt-api-base'),
|
||||
request_timeout: val('mqtt-timeout'),
|
||||
on_demand_duration: val('mqtt-duration') === '' ? null : val('mqtt-duration'),
|
||||
log_level: val('mqtt-log-level'),
|
||||
mqtt_tls: !!(document.getElementById('mqtt-tls') || {}).checked,
|
||||
};
|
||||
// Only send a password when one was typed; blank means "leave it alone".
|
||||
const pw = val('mqtt-password');
|
||||
if (pw) body.mqtt_password = pw;
|
||||
return body;
|
||||
}
|
||||
|
||||
window.saveMqttBridge = function() {
|
||||
setBusy('btn-mqtt-save', true);
|
||||
fetch(MQTT_BRIDGE_URL + '/config', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(mqttBody())
|
||||
})
|
||||
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
||||
.then(({ok, d}) => {
|
||||
showResult('result-mqtt-save', ok && d.status === 'success',
|
||||
d.message || (ok ? 'Saved' : 'Failed'));
|
||||
if (ok && d.status === 'success') loadMqttBridge();
|
||||
})
|
||||
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message))
|
||||
.finally(() => setBusy('btn-mqtt-save', false));
|
||||
};
|
||||
|
||||
function clearMqttPassword() {
|
||||
if (!confirm('Remove the stored MQTT password?')) return;
|
||||
fetch(MQTT_BRIDGE_URL + '/config', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({clear_password: true})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
showResult('result-mqtt-save', d.status === 'success', d.message || 'Password cleared');
|
||||
loadMqttBridge();
|
||||
})
|
||||
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
window.installMqttBridge = function() {
|
||||
// Installing pulls dependencies, so it is slower than the other actions
|
||||
// and worth saying so rather than leaving a spinner unexplained.
|
||||
showResult('result-mqtt-service', true, 'Installing — this pulls Python dependencies and can take a minute…');
|
||||
setBusy('btn-mqtt-install', true);
|
||||
fetch('/api/v3/system/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'mqtt_bridge_install'})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
showResult('result-mqtt-service', d.status === 'success',
|
||||
d.message || (d.status === 'success' ? 'Installed' : 'Failed'), d.output || '');
|
||||
loadMqttBridge();
|
||||
})
|
||||
.catch(err => showResult('result-mqtt-service', false, 'Request failed: ' + err.message))
|
||||
.finally(() => setBusy('btn-mqtt-install', false));
|
||||
};
|
||||
|
||||
// ── Pixlet config editor ──────────────────────────────────────────────────
|
||||
|
||||
const PIXLET_EDITOR_URL = '/api/v3/starlark/editor';
|
||||
let pixletPollTimer = null;
|
||||
|
||||
function fmtRemaining(seconds) {
|
||||
if (seconds === null || seconds === undefined) return '';
|
||||
const m = Math.floor(seconds / 60), s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function editorHost(port) {
|
||||
// The session binds 0.0.0.0, so whatever host reached this page reaches
|
||||
// the editor too -- no tunnel, and no guessing the device's address.
|
||||
return `${window.location.protocol}//${window.location.hostname}:${port}/`;
|
||||
}
|
||||
|
||||
function renderPixletEditor(status, apps, pixletAvailable, appsDir) {
|
||||
const body = document.getElementById('pixlet-editor-body');
|
||||
if (!body) return;
|
||||
|
||||
if (!pixletAvailable) {
|
||||
body.innerHTML = `
|
||||
<p class="text-sm text-gray-700">
|
||||
Pixlet is not installed, so there is nothing to edit with. Install it from the
|
||||
Starlark Apps section of the Plugins tab, or run
|
||||
<code class="bg-gray-100 px-1 rounded">./scripts/download_pixlet.sh</code>.
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const active = status.running ? `
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-900">
|
||||
<i class="fas fa-circle-notch fa-spin mr-1"></i>
|
||||
Editing <code class="bg-amber-100 px-1 rounded">${escHtml(status.app_id)}</code> — the display is stopped
|
||||
</p>
|
||||
<p class="text-xs text-amber-800 mt-1">
|
||||
<a href="${editorHost(status.port)}" target="_blank" rel="noopener"
|
||||
class="underline font-medium">Open the editor →</a>
|
||||
<span class="ml-2">stops on its own in
|
||||
<span id="pixlet-countdown">${fmtRemaining(status.seconds_remaining)}</span></span>
|
||||
</p>
|
||||
</div>
|
||||
<button id="btn-pixlet-stop" onclick="stopPixletEditor()"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||
<i class="fas fa-stop mr-2"></i>Stop now
|
||||
</button>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
const rows = apps.length ? apps.map(a => `
|
||||
<div class="flex items-center justify-between gap-4 py-2 border-t border-gray-100 first:border-t-0">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">${escHtml(a.name)}</p>
|
||||
<p class="text-xs text-gray-500 truncate">${escHtml(a.id)}${a.editable ? '' : ' — no .star file'}</p>
|
||||
</div>
|
||||
<button id="btn-pixlet-edit-${escHtml(a.id)}"
|
||||
${(!a.editable || status.running) ? 'disabled' : ''}
|
||||
data-pixlet-edit data-app-id="${escHtml(a.id)}"
|
||||
class="shrink-0 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 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-sliders-h mr-2"></i>Edit
|
||||
</button>
|
||||
</div>`).join('') : `
|
||||
<p class="text-sm text-gray-500">
|
||||
No Starlark apps installed. Install one from the Plugins tab first
|
||||
(looked in <code class="bg-gray-100 px-1 rounded">${escHtml(appsDir)}</code>).
|
||||
</p>`;
|
||||
|
||||
body.innerHTML = active + `<div class="mt-2">${rows}</div>
|
||||
<div id="result-pixlet" class="hidden"></div>`;
|
||||
|
||||
// Bound here rather than via an inline onclick: escHtml does not encode
|
||||
// single quotes, and the id used to be interpolated into a single-quoted
|
||||
// JS string, so an app directory containing an apostrophe could break
|
||||
// out of it and run script. Through dataset the value is only ever
|
||||
// parsed as an HTML attribute, never as JavaScript.
|
||||
body.querySelectorAll('[data-pixlet-edit]').forEach(btn => {
|
||||
btn.addEventListener('click', () => startPixletEditor(btn.dataset.appId));
|
||||
});
|
||||
}
|
||||
|
||||
window.loadPixletEditor = function() {
|
||||
Promise.all([
|
||||
fetch(PIXLET_EDITOR_URL + '/status').then(r => r.json()),
|
||||
fetch(PIXLET_EDITOR_URL + '/apps').then(r => r.json())
|
||||
])
|
||||
.then(([s, a]) => {
|
||||
const status = (s.data || {});
|
||||
const apps = (a.data || {}).apps || [];
|
||||
renderPixletEditor(status, apps, (a.data || {}).pixlet_available, (a.data || {}).apps_dir || '');
|
||||
schedulePixletPoll(status.running);
|
||||
})
|
||||
.catch(err => {
|
||||
const body = document.getElementById('pixlet-editor-body');
|
||||
if (body) body.innerHTML = `<p class="text-sm text-red-600">Could not load: ${escHtml(err.message)}</p>`;
|
||||
});
|
||||
};
|
||||
|
||||
function schedulePixletPoll(running) {
|
||||
// Poll only while a session is open: that is the state worth watching,
|
||||
// and it is what makes the banner survive a page reload.
|
||||
if (pixletPollTimer) { clearInterval(pixletPollTimer); pixletPollTimer = null; }
|
||||
if (!running) return;
|
||||
pixletPollTimer = setInterval(() => {
|
||||
fetch(PIXLET_EDITOR_URL + '/status')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const st = d.data || {};
|
||||
if (!st.running) { loadPixletEditor(); return; }
|
||||
const el = document.getElementById('pixlet-countdown');
|
||||
if (el) el.textContent = fmtRemaining(st.seconds_remaining);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
window.startPixletEditor = function(appId) {
|
||||
const btnId = 'btn-pixlet-edit-' + appId;
|
||||
setBusy(btnId, true);
|
||||
fetch(PIXLET_EDITOR_URL + '/start', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({app_id: appId})
|
||||
})
|
||||
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
||||
.then(({ok, d}) => {
|
||||
if (!ok || d.status !== 'success') {
|
||||
showResult('result-pixlet', false, d.message || 'Could not start the editor');
|
||||
return;
|
||||
}
|
||||
loadPixletEditor();
|
||||
})
|
||||
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
|
||||
.finally(() => setBusy(btnId, false));
|
||||
};
|
||||
|
||||
window.stopPixletEditor = function() {
|
||||
setBusy('btn-pixlet-stop', true);
|
||||
fetch(PIXLET_EDITOR_URL + '/stop', {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(d => { showResult('result-pixlet', d.status === 'success', d.message || 'Stopped'); loadPixletEditor(); })
|
||||
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
|
||||
.finally(() => setBusy('btn-pixlet-stop', false));
|
||||
};
|
||||
|
||||
// Initial load for both sections. Down here rather than at the shared init
|
||||
// site above, because these are assignments to window rather than hoisted
|
||||
// declarations -- calling them earlier in the file throws.
|
||||
loadMqttBridge();
|
||||
loadPixletEditor();
|
||||
|
||||
})();
|
||||
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user