mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(sports): rebuild un-shared faces through the pinned layout engine unshare_element_fonts re-instantiates a duplicate font face so two elements can be told apart by id(). It did so through bare ImageFont.truetype, which takes PIL's default layout engine rather than the one src/common/font_layout.py pins. Raqm and Basic disagree on fractional advances -- that disagreement is the reason the pin exists, having broken golden images across machines -- so a rebuilt face could measure differently from the shared face it replaced, on any host where Raqm is installed. These were the only two call sites in src/ bypassing the pin. The guard asserts that the rebuild goes through the pinned loader rather than comparing engine values: where Raqm is absent, bare truetype returns BASIC anyway, so an engine comparison passes whether or not the pin is honoured. The first draft of this test did exactly that and passed with the bug reintroduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(web): drop the two dead client-side config-form renderers generateConfigForm and generateSimpleConfigForm (580 lines) were defined on the Alpine component and never called: server-side Jinja replaced them, as pages_v3.py:641 records. Nothing in any template invokes them -- there is no x-html in the templates and no bracket access on the component. They carried their own x-widget dispatch, which made them an active trap: the next person adding a widget would reasonably think both renderers needed updating. plugins/config_manager.js (PluginConfigManager, 133 lines) goes for the same reason -- loaded on every page from base.html, referenced only by itself and by an archived doc. Kept, having checked them: widgets/example-color-picker.js is the worked example docs/widget-guide.md points plugin authors at, and widgets/plugin-loader.js is the client half of a documented feature (manifest-declared plugin widgets) whose server route is missing -- soccer-scoreboard already ships a widgets/custom-leagues.js that this loader is meant to fetch. That is an unfinished feature to complete, not dead code to delete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): serve plugin-declared widgets, and actually ask for them LEDMatrixWidgets.loadPluginWidget has always fetched /static/plugin-widgets/<plugin>/<widget>.js, and docs/widget-guide.md has always documented that path, but nothing served it. soccer-scoreboard has shipped a 17KB widgets/custom-leagues.js since August that could never load. Both halves were missing, not just the route: - serve_plugin_widget serves the script from the plugin's widgets/ directory as text/javascript. The manifest is the allowlist -- only a widget the plugin declares is reachable -- so installing a plugin does not publish everything it ships. Path handling mirrors the sibling serve_plugin_web_ui: allowlist regexes, os.path.basename, resolve() + relative_to() containment, and the ledmatrix- prefix fallback. The declared script name is guarded too, since it comes from the plugin rather than the request. - The config form never requested one. Its x-widget dispatch is a hardcoded list of core widget names, so a plugin's own widget fell through to a plain text input. An unrecognised x-widget on a string field now asks ensureWidget() for it. The text input stays as the fallback and is removed only once the widget has actually rendered, so a missing or broken widget costs the user an editor rather than their configured value on the next save. - manifest_schema.json gains "widgets", so the declaration is validated rather than merely tolerated by additionalProperties. Verified in a browser against the real partial: a declared widget loads, registers and renders, and its field posts exactly one value; a field whose widget 404s keeps its text input and still posts its value. Not addressed: loadPluginWidgetsFromManifest still has no caller. The per-field ensureWidget path is lazier and is what the form now uses, so that bulk helper is dead weight -- worth removing, but left alone here rather than inventing a call site for it. Known limitation, documented: only string-typed fields take this path. object/array/boolean/number fields and enums are dispatched by the template's own branches, which still only know core widgets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(element-style): a wrong-size BDF now keeps its font, not its size BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel size baked into the file and raises for anything else. 32 of the 35 shipped fonts are BDF, so a size picked in the web UI usually is not a valid strike -- and load_font caught that failure with its generic "unloadable font" handler, which substitutes PressStart2P. Asking for 5x7.bdf at size 10 therefore rendered a completely different typeface, silently. It now falls back to the file's own native size instead, which is what SportsCore._load_custom_font_from_element_config has always done. The native size is read via FontManager._read_bdf_native_size rather than a fourth copy of that parser, matching how core.py already delegates. Also here, because they are the same code path: - native_bdf_size() is exposed for the web UI, which needs to know when a size field can take effect at all. None means "free choice". - ElementStyle.font_size now reports the size actually realised rather than the one requested. Callers lay out from it, and reserving space for a size nothing was drawn at is how this surfaces. - The module font cache is a bounded LRU (256) instead of an unbounded dict. The display process runs for weeks and every config save can add a (font, size) pair; every other hot cache in the codebase is bounded this way. Untouched configs are unaffected: the shipped classic fonts are the three TTFs, so nothing was hitting the substitution path by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): per-mode style and offset overrides Lets one element be styled differently per situation -- a scoreboard's live / upcoming / recent cards, weather's current / hourly / daily screens -- under customization.modes.<mode>. The mode is bound at construction rather than passed per call. That is what makes this cheap to adopt: SportsUpcoming and SportsRecent are already separate instances with distinct SKIN_MODE values, so binding once makes every existing style()/offset_value() call site mode-aware without editing any of them. A per-call mode argument exists for the rare host that renders more than one mode. The two layers answer different questions, deliberately: - The base layer keeps the existing "differs from the schema default" rule, because the save flow writes the full default object into config.json whether or not the user touched it. - A mode layer is pure override -- its fields default to None, so presence is intent. Nothing writes into it unasked, so there is nothing for the stricter rule to protect against. None therefore means inherit, and has to stay distinct from 0: a mode y_offset of 0 means "sit at the base position", not "no preference". This is the same distinction scroll_card.switch_* draws with "inherit". A malformed mode value falls back to the resolved base value rather than to the caller's default -- caught by the degradation tests, which is what they are for: resolving the mode first let one bad string in a mode block silently discard a good base offset. With no modes block, and for every existing caller, resolution is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): declare per-mode overrides in config_schema.json A plugin adds "x-style-modes": ["live", "upcoming", "recent"] alongside its x-style-elements declaration and gets a customization.modes.<mode> group per mode, with every field of every declared element repeated as an override. Those override fields are typed nullable and default to null, which is the whole trick. The save flow writes schema defaults into config.json wholesale, so giving a mode field the base element's default would make every mode a frozen copy of the base the first time a user pressed Save, and the base would stop reaching them. Null means inherit. The mutation test for this is explicit: with concrete defaults, a base font_size of 14 resolves as 10 with user_forced set. min/max from the declaration carry into the mode blocks, so an out-of-range override is rejected by validation rather than clamped silently at render time. Also: the emitted font field now carries "x-widget": "font-selector". The widget already shipped and the config form already allowlisted it -- the hint was simply never emitted, so the field rendered as a bare text box that the user had to type a font filename into. Verified through the real SchemaManager path -- load_schema, defaults extraction, merge_with_defaults, validation, then resolution -- rather than against a hand-built dict, since the thing at risk is what that pipeline does to a null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): render the config form from the schema the save route validates The form read config_schema.json with a raw json.load while api_v3.save_plugin_config went through SchemaManager. Those are not the same schema: SchemaManager applies expand_style_elements, which turns a compact customization.x-style-elements declaration into the per-element blocks the form knows how to render. Without it, that customization object has an x-style-elements key and no "properties", so the template's object branch matched nothing and the section rendered as empty space -- while saving still validated against the expanded shape. of-the-day ships the compact form, so its customization section has been invisible in the web UI. pages_v3 gains a schema_manager the way it already has config_manager and plugin_manager. use_cache=False matches the save route, so an edited schema is not served stale during plugin development. The raw read stays as a fallback for callers that register this blueprint without one. Checked before making the change: load_schema does nothing here except read, validate and expand -- inject_skin_selector is a separate method it does not call -- so this is not a behaviour change for schemas without the declaration. The test pair renders the same compact schema with and without a SchemaManager, so it documents exactly what was broken as well as what is fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): style-editor widget -- a row per element instead of 65 accordions Rendered element by element, a realistic scoreboard's customization block is 65 nested sections, and reaching one per-mode font size takes five levels of expanding. The widget collapses that to one compact row per element -- font, size, colour, X, Y -- with a tab per declared mode. It emits ordinary inputs under the same dotted names the generic renderer would produce, so the save/validate/merge pipeline is untouched: no hidden JSON blob and no new server-side parsing. It is driven entirely by the schema block it is handed, so fields added to the schema later appear without editing the widget. If it fails to load or throws, the generic nested rendering it replaces is left in place. Fixing two things the save path got wrong for nullable fields, found by posting what the widget actually emits: - The indexed-array recombiner (text_color.0/.1/.2 -> one list) compared the declared type to the string 'array', so a per-mode colour, typed ["array", "null"], was never reassembled and failed validation on save. _parse_form_value_with_schema had the same comparison. - A blank nullable field became [] rather than None, which then failed the minItems the colour array declares. Null is the inherit sentinel, so it has to survive. And two things the widget itself got wrong, found by looking at it: - An unset base control fell back to the select's first option, so an untouched scoreboard claimed every element used 10x20.bdf -- and the size box then locked itself to that bitmap font's fixed size. Base controls now show the schema default; mode controls stay blank, because blank there means inherit. - Elements arrived alphabetised (Detail and Odds above Score). Flask's JSON provider sorts keys, so declaration order has to be stated explicitly; expand_style_elements now emits x-propertyOrder, which the generic renderer already honoured too. Size is disabled and shown as fixed for a bitmap font, using the scalable/native_size the font catalog now reports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): visibility, alignment and scale per element Completes the customization vocabulary: hide an element, align it, and resize a logo, alongside the font/size/colour/offset that already existed. All three per mode. They resolve to "change nothing" until the user asks for something -- True, None and 1.0 -- rather than to whatever the schema declares. That is the same invariant the font fields keep: a caller that honours them still renders an untouched config exactly as it did before they existed. A schema default therefore does not count as a choice, which matters because the save flow writes that default into config either way. scale sits in the layout block with the offsets rather than in the element block, because it is geometry: a logo has a scale and no font. The widget's columns come from the schema, so a logo row shows visibility, offsets and scale and no empty font cell. Two bugs found by the tests rather than by reading: - A nullable enum needs null in its enum list, not just in its type. The mode copy of `align` defaulted to null and then failed its own schema, so a plugin declaring any enum field with modes could not save at all. Six tests failed on this before any of them reached what they were testing. - defaults_from_schema only ever extracted font/font_size/text_color, so the schema defaults for the new fields were invisible to the resolver and a declared default read as a user choice. Widget: the table scrolls horizontally and pins the element-name column. Nine columns do not fit the config panel, and clipping them hid the offsets entirely while scrolling them made every row anonymous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): resolve elements under the names plugins actually use Two naming conventions collided as the scoreboards grew. Counted across the published schemas: the style block names elements with a _text suffix (score_text, status_text, detail_text), while the layout block mostly uses the bare noun (score, date, time, odds) -- except status_text, which kept the suffix in seven plugins and lost it in two. records vs record splits seven to two the same way. A lookup now tries the exact name first and then the spellings that mean the same thing. Exact-first is what makes this inert for any config that already matches; the aliases only decide cases that resolved to nothing before. This is also what makes migrating to the compact declaration form safe. That form uses one key for both blocks, so a scoreboard adopting it asks for layout.score_text while its users have layout.score saved -- without the aliases, every offset they had dialled in would silently become 0. Applies to the style block, the layout block, the schema defaults and the per-mode overrides, since the drift shows up in all four. Not attempting to canonicalise on write: renaming keys in config.json would break the plugins still reading the old spelling from their own bundled code, and the drift costs a dict miss rather than correctness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(plugins): BasePlugin.styles -- per-element styling every plugin inherits Adopting the element-style system meant repeating three things in every plugin: a guarded import, finding its own config_schema.json, and rebuilding the resolver when on_config_change swapped the config dict. This is those three things once, on the class all 45 plugins already inherit from. title = self.styles.style('title_text', classic_font='PressStart2P-Regular.ttf', classic_size=8, classic_color=(255, 255, 255)) The classic_* arguments are the adoption contract: with nothing configured they come back verbatim, so a plugin that switches to this renders exactly as before until a user changes something. A plugin with one instance per display mode sets STYLE_MODE on the class and every existing lookup becomes mode-aware without a call site changing -- which is the point of binding the mode to the resolver rather than passing it per call. styles_for() covers a plugin that renders several modes from one instance. Schema discovery reads the concrete class's own module rather than this file, because this file lives in src/plugin_system where no plugin schema exists -- the same trap SportsCore._config_schema_path documents. The first mutation test for that passed anyway: an installed plugin's module directory and its entry under plugins_dir are the same path, so the test could not tell the two apart. The case where they diverge is a plugin symlinked in for development, and the test now forces that shape. Getting discovery wrong is silent rather than loud: with no schema the resolver has no defaults to compare against, so every configured value reads as a deliberate override and the plugin quietly stops honouring its own shipped styling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): adopt hand-written customization blocks, and widen the font list Nineteen plugins spell their style elements out longhand instead of declaring them -- football's block is 701 lines for seven elements -- and predate this system entirely. Core now recognises that shape, so they pick up the row-per-element editor and the real font picker on a core update rather than on a plugin release. Checked against every published schema: 21 plugins adopt, and the defaults of each still validate against the schema generated for it. Detection requires *every* field in a block to be one this system understands. A looser "has at least one style field" rule sweeps in baseball's `count`, which carries a text_color beside geometry that means nothing here. That distinction took three attempts to test: the first two assertions passed under both rules, because an over-eager rule leaves a fontless block looking untouched and only surfaces as an extra row in the editor. The hardcoded font enum is replaced rather than extended. Football lists five of the thirty-five installed fonts, which is why a font a user uploads can never appear in one. It is not a curated safe set -- it omits some twenty other faces that fit the declared size cap just as well -- it is the fonts that happened to exist when it was written. Widening it does need a guard, though, and not the one the schema already has: a bitmap font ignores font_size and renders at its size baked into the file, so `maximum: 16` cannot stop a 27px face. The picker now filters out fixed-size fonts taller than the element's own declared ceiling, which drops exactly the four that would overflow a 32px panel and keeps the other thirty. Per-mode overrides stay opt-in: core cannot invent a plugin's display modes, so `x-style-modes` remains the one line that unlocks them. Their layout half covers every positionable element rather than only those with a style block -- the two namespaces do not line up in a hand-written schema, and football positions six things (logos, timeouts, possession) that have no style block at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(web): remove the two Fonts-tab panels that reported invented data "Element Font Overrides" let a user configure an override, showed a success toast, and changed nothing. All three endpoints behind it were stubs -- GET returned a hardcoded {}, POST and DELETE returned success without calling anything -- each marked "This would integrate with the actual font system". Wiring them to FontManager would not have fixed it. The machinery there is real (_load_overrides/_save_overrides persist config/font_overrides.json, resolve_font applies them, and the countdown plugin genuinely consumes it), but the panel's element dropdown offered eleven invented keys -- nfl.live.score, clock.time, weather.current -- that no plugin has ever read. An override saved against one of those would have persisted correctly and still done nothing. "Detected Manager Fonts" goes for the same reason. It claimed to show "fonts currently in use by managers (auto-detected)"; its own comment said "we'll simulate this", and it listed every font in the catalog with a hardcoded usage_count of 1 -- the panel beside it, with fabricated numbers attached. Per-element font choice now lives in each plugin's own config editor, against the elements that plugin actually has, and covers size, colour, offsets, visibility, alignment and scale rather than family and size. Kept: the font library (upload, preview, delete), which works, and /fonts/tokens, which is a stub but genuinely feeds the preview's size dropdown. FontManager's override methods are untouched -- countdown uses them. Verified in a browser with the tab's JS running: no console errors, 35 fonts listed, upload and preview intact. Removing the panel meant unwiring it from populateFontSelects too, which would otherwise have bailed out early on the missing select and left the preview dropdown empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(sports): one reader for element colours and layout offsets There were two copies of the per-element colour read and three of the layout-offset read. They had already drifted -- the scroll-card renderer carries a comment about having ignored offsets its own schema advertised -- and each new capability had to be added to all of them or silently work in some places and not others. All of them now go through src.element_style, which is what carries the alias handling and the per-mode lookup. That lands immediately for the nine plugins importing these modules: a scoreboard asking for `score_text` offsets finds the `layout.score` its users configured, and a Live instance resolves its own colours through SKIN_MODE without any call site passing a mode. _normalize_color learned "#RRGGBB" in the process. The scoreboards' own readers have always accepted it, so the shared one had to, or consolidating would have quietly dropped a form users' configs may hold. _coerce_offset picked up the non-finite guard the scroll-card reader had and the other two did not. _get_layout_offset is promoted onto SportsCoreSharedMixin. Each plugin still carries its own copy in its bundled sports.py, which wins by MRO -- so adopting this is a deletion in the plugin, and until that deletion nothing changes for it. Note for whoever runs the suite next: test_display_dirty_tracking.py is order-dependent. Fifteen of its tests failed in one full run and passed in the next with no change in between, and pass in isolation. Pre-existing, unrelated to this, but it makes a full-run diff untrustworthy until it is fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): record the element-style work under Unreleased This file's own preamble asks for it: a plugin may delete its bundled fallback copy of a core module only when its manifest floors on the first release that shipped that module, which requires the additions to be recorded here against a version. Names a plugin can now import and floor on -- the stateless layout_offset and element_color readers, alias_keys, native_bdf_size, the resolver's mode binding, BasePlugin.styles, and the promoted SportsCoreSharedMixin._get_layout_offset -- plus the schema and web-UI changes, the four fixes and the three removals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(fonts): log the BDF native-size read failure instead of swallowing it The bdf-native-size lookup in get_fonts_catalog() caught any exception and silently discarded it. Every other guarded read added in this PR (the manifest parse in _declared_widget_script, the SchemaManager fallback in _load_plugin_config_partial) logs before falling through to the same degraded behavior. This one didn't, which is the shape a silent-exception-swallow lint rule flags. Behavior is unchanged -- native_size still comes back None -- but a corrupt or unreadable BDF file now leaves a trace. Verified: font-related tests (140) and the full suite still pass, with only the 2 pre-existing Europe/Kiev/Asia/Calcutta tzdata-alias failures already present on origin/main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: address CodeRabbit findings on the style-editor/font-selector PR - Fix _load_font_sized double-wrapping the (font, size) tuple on the missing-font path, which handed callers a tuple instead of a font. - Fix _set_nested_value skipping an explicit None when the key already existed, which silently kept stale overrides when a user cleared a nullable per-mode field or blanked all channels of an indexed color. - Preserve BDF scalable/native_size metadata through fetchFontCatalog's catalog-format mapping so maxFixedSize filtering actually applies. - Stop caching an empty array on a failed font-catalog fetch so a later call can retry instead of being stuck with the failed result. - Keep a saved font selected in the style editor even when it no longer fits a newly declared maxFixedSize, instead of silently deselecting it. - Don't drop in-progress user edits to fallback fields when a plugin widget finishes loading asynchronously and takes over the form. - Tighten the removed font-override endpoint test to assert 405, not just != 200. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): a partial save no longer switches off checkboxes it never showed An HTML checkbox posts nothing when unchecked, so the save route walked the schema and forced every boolean missing from the form to False. That is right for the rendered form and wrong for every other caller: a script, the MQTT bridge or a curl against the documented endpoint never rendered a checkbox, and reading its silence as "all off" turns a one-field save into a mass disable. Found on hardware. Posting four customization.* keys to a live device switched off nfl.enabled, ncaa_fb.enabled and every display-mode toggle in one request. The form now reports the top-level sections it drew (__rendered_section), and inside those an absent checkbox still means unchecked -- including a section whose only fields are checkboxes that are all off, which no heuristic could recover. A post with no marker only touches objects it actually posted a field from. Meta fields are dropped before form keys are treated as config paths, because unknown keys are otherwise written straight into config.json. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(sports): resolve element colour by name, and honour visible/align/scale Two of the three gaps this framework shipped with. Colour by name. A draw resolved its colour by comparing the *identity* of the font object it was handed, which cannot tell two elements apart when they share a face -- so those draws went out white. Every bitmap font is in that case, because a freetype.Face cannot be re-instantiated to un-share it, which is how an element rendered in any of the 32 shipped BDF fonts silently lost a colour its picker had offered all along. _draw_text_with_outline now takes element="score_text" and reads the colour by name; the identity path remains for un-annotated callers, but narrows before giving up -- one configured colour among the sharers is the only thing the user can have meant. Visible, align and scale. The resolver has understood these since the framework landed and nothing consumed them: an element could be marked hidden in the web UI and still render. Adds the stateless readers, the mixin accessors, and a scale parameter on the one shared logo-sizing seam (keyed into the cache, so two elements scaled differently cannot be served each other's image). Naming an element in a draw also honours its visibility. Untouched configs are unaffected: every new parameter defaults to today's behaviour, and all ten affected plugins render pixel-identically to main across every harness size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(plugins): how to declare styleable elements; harden the widget's lookups The plugin-author guide for the compact x-style-elements declaration -- what each key does, how to read values back without breaking the "user-forced only when it differs from the default" rule, and why a hand-written block needs no changes to be adopted. Also clears the static-analysis findings on style-editor.js. Every lookup in that file is keyed by something out of a schema or a saved config, so a key of __proto__ or constructor would walk the prototype chain and hand back a function instead of a schema; reads now go through an own-property helper. The panel registry became a list, and the flagged vars moved to their function roots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear the remaining static-analysis findings Five, all on lines this branch touched. The Python one is not a new defect: _set_missing_booleans_to_false's first parameter was always named `config`, which shadows the `config` submodule imported for its side effects at the bottom of this module. Editing the signature simply put the existing warning on a changed line. The parameter is the plugin's config dict, so `plugin_config` is what it should have been called anyway; callers pass it positionally and are unaffected. The JavaScript ones are the object-injection rule firing on reads keyed by data. own() now goes through a property descriptor, so the one unavoidable data-keyed read is no longer a computed member access; at() consumes its path instead of indexing it; and the column set is a Map, which has no prototype to pollute and needs no guarded reads at all. Verified the widget still renders identically against football's real schema: 29 element rows, all four mode tabs, values populated, no console errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): drop the hasOwnProperty alias the descriptor read made redundant own() now reads through Object.getOwnPropertyDescriptor, so the alias it used to call has no remaining reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2473 lines
126 KiB
JavaScript
2473 lines
126 KiB
JavaScript
/* global debugLog */
|
|
// SSE wiring + full Alpine app() implementation and tab logic
|
|
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
|
// Assign to window so reconnectSSE() in app.js can reach them.
|
|
window.statsSource = new EventSource('/api/v3/stream/stats');
|
|
window.displaySource = new EventSource('/api/v3/stream/display');
|
|
|
|
window.statsSource.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
updateSystemStats(data);
|
|
};
|
|
|
|
window.displaySource.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
updateDisplayPreview(data);
|
|
};
|
|
|
|
function _setConnectionStatus(connected, reconnecting) {
|
|
const el = document.getElementById('connection-status');
|
|
if (!el) return;
|
|
if (connected) {
|
|
el.innerHTML = `
|
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<span class="text-gray-600">Connected</span>
|
|
`;
|
|
} else if (reconnecting) {
|
|
el.innerHTML = `
|
|
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
|
<span class="text-gray-600">Reconnecting…</span>
|
|
`;
|
|
} else {
|
|
el.innerHTML = `
|
|
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
|
|
`;
|
|
}
|
|
}
|
|
|
|
var _statsErrorCount = 0;
|
|
|
|
// Named on window so reconnectSSE() in app.js can reattach them after
|
|
// replacing the EventSource instances.
|
|
window._statsOpenHandler = function() {
|
|
_statsErrorCount = 0;
|
|
_setConnectionStatus(true, false);
|
|
};
|
|
window._statsErrorHandler = function() {
|
|
_statsErrorCount++;
|
|
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
|
|
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
|
|
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
|
|
};
|
|
window._displayErrorHandler = function() {
|
|
// Display stream errors don't change the status badge but log to console
|
|
// so failures aren't completely silent.
|
|
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
|
|
};
|
|
|
|
window.statsSource.addEventListener('open', window._statsOpenHandler);
|
|
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
|
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
|
|
|
// Reset any time the currently-active warning clears, so a future
|
|
// (new) occurrence shows the banner again even if this one was dismissed.
|
|
window._powerWarningDismissed = false;
|
|
|
|
window.dismissPowerWarningBanner = function() {
|
|
const banner = document.getElementById('power-warning-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
window._powerWarningDismissed = true;
|
|
};
|
|
|
|
// Labels for whichever flags from _get_power_status() are set (pass
|
|
// suffix='_occurred' for the "happened earlier" variant), used to
|
|
// build accurate banner/tooltip text instead of hardcoding
|
|
// "under-voltage" for what may actually be throttling/freq-capping/
|
|
// thermal limiting.
|
|
function _activePowerConditionLabels(power, suffix) {
|
|
suffix = suffix || '_now';
|
|
const labels = [];
|
|
if (power['under_voltage' + suffix]) labels.push('under-voltage');
|
|
if (power['throttled' + suffix]) labels.push('throttling');
|
|
if (power['freq_capped' + suffix]) labels.push('CPU frequency capped');
|
|
if (power['soft_temp_limit' + suffix]) labels.push('soft thermal limit');
|
|
return labels;
|
|
}
|
|
|
|
function updatePowerStatus(power) {
|
|
const statEl = document.getElementById('power-stat');
|
|
const banner = document.getElementById('power-warning-banner');
|
|
const bannerText = document.getElementById('power-warning-banner-text');
|
|
|
|
if (!power) {
|
|
if (statEl) statEl.classList.add('hidden');
|
|
if (banner) {
|
|
banner.style.display = 'none';
|
|
// Let a future occurrence show the banner again rather
|
|
// than leaving stale text/visibility from before this
|
|
// (likely transient) missing-data tick.
|
|
window._powerWarningDismissed = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const activeNow = power.under_voltage_now || power.throttled_now ||
|
|
power.freq_capped_now || power.soft_temp_limit_now;
|
|
const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred ||
|
|
power.freq_capped_occurred || power.soft_temp_limit_occurred;
|
|
|
|
if (statEl) {
|
|
statEl.classList.remove('text-red-600', 'text-yellow-600');
|
|
if (activeNow) {
|
|
statEl.classList.remove('hidden');
|
|
statEl.classList.add('flex', 'text-red-600');
|
|
statEl.title = _activePowerConditionLabels(power).join('/') +
|
|
' detected right now — check your power supply and cooling';
|
|
} else if (occurredEarlier) {
|
|
statEl.classList.remove('hidden');
|
|
statEl.classList.add('flex', 'text-yellow-600');
|
|
const occurredLabels = _activePowerConditionLabels(power, '_occurred');
|
|
statEl.title = (occurredLabels.length ? occurredLabels.join('/') : 'An issue') +
|
|
' was detected earlier (currently OK)';
|
|
} else {
|
|
statEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
if (banner) {
|
|
if (activeNow) {
|
|
if (bannerText) {
|
|
const labels = _activePowerConditionLabels(power);
|
|
bannerText.textContent = (labels.length ? labels.join('/') : 'A power/thermal issue') +
|
|
' detected right now — the display may flicker or degrade. Check your power supply and cooling.';
|
|
}
|
|
if (!window._powerWarningDismissed) {
|
|
banner.style.display = '';
|
|
}
|
|
} else {
|
|
banner.style.display = 'none';
|
|
// Let a future occurrence show the banner again.
|
|
window._powerWarningDismissed = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateSystemStats(data) {
|
|
// Update CPU in header
|
|
const cpuEl = document.getElementById('cpu-stat');
|
|
if (cpuEl && data.cpu_percent !== undefined) {
|
|
const spans = cpuEl.querySelectorAll('span');
|
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_percent + '%';
|
|
}
|
|
|
|
// Update Memory in header
|
|
const memEl = document.getElementById('memory-stat');
|
|
if (memEl && data.memory_used_percent !== undefined) {
|
|
const spans = memEl.querySelectorAll('span');
|
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.memory_used_percent + '%';
|
|
}
|
|
|
|
// Update Temperature in header
|
|
const tempEl = document.getElementById('temp-stat');
|
|
if (tempEl && data.cpu_temp !== undefined) {
|
|
const spans = tempEl.querySelectorAll('span');
|
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C';
|
|
}
|
|
|
|
// Update Power (under-voltage / throttling) status in header + banner
|
|
updatePowerStatus(data.power);
|
|
|
|
// Update Overview tab stats (if visible)
|
|
const cpuUsageEl = document.getElementById('cpu-usage');
|
|
if (cpuUsageEl && data.cpu_percent !== undefined) {
|
|
cpuUsageEl.textContent = data.cpu_percent + '%';
|
|
}
|
|
|
|
const memUsageEl = document.getElementById('memory-usage');
|
|
if (memUsageEl && data.memory_used_percent !== undefined) {
|
|
memUsageEl.textContent = data.memory_used_percent + '%';
|
|
}
|
|
|
|
const cpuTempEl = document.getElementById('cpu-temp');
|
|
if (cpuTempEl && data.cpu_temp !== undefined) {
|
|
cpuTempEl.textContent = data.cpu_temp + '°C';
|
|
}
|
|
|
|
const displayStatusEl = document.getElementById('display-status');
|
|
if (displayStatusEl) {
|
|
displayStatusEl.textContent = data.service_active ? 'Active' : 'Inactive';
|
|
displayStatusEl.className = data.service_active ?
|
|
'text-lg font-medium text-green-600' :
|
|
'text-lg font-medium text-red-600';
|
|
}
|
|
}
|
|
|
|
window.__onDemandStore = window.__onDemandStore || {
|
|
loading: true,
|
|
state: {},
|
|
service: {},
|
|
error: null,
|
|
lastUpdated: null
|
|
};
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
// On-Demand state store
|
|
if (window.Alpine && !window.Alpine.store('onDemand')) {
|
|
window.Alpine.store('onDemand', {
|
|
loading: window.__onDemandStore.loading,
|
|
state: window.__onDemandStore.state,
|
|
service: window.__onDemandStore.service,
|
|
error: window.__onDemandStore.error,
|
|
lastUpdated: window.__onDemandStore.lastUpdated
|
|
});
|
|
}
|
|
if (window.Alpine) {
|
|
window.__onDemandStore = window.Alpine.store('onDemand');
|
|
}
|
|
|
|
// Plugin state store - centralized state management for plugins
|
|
// Used primarily by HTMX-loaded plugin config partials
|
|
if (window.Alpine && !window.Alpine.store('plugins')) {
|
|
window.Alpine.store('plugins', {
|
|
// Track which plugin configs have been loaded
|
|
loadedConfigs: {},
|
|
|
|
// Mark a plugin config as loaded
|
|
markLoaded(pluginId) {
|
|
this.loadedConfigs[pluginId] = true;
|
|
},
|
|
|
|
// Check if a plugin config is loaded
|
|
isLoaded(pluginId) {
|
|
return !!this.loadedConfigs[pluginId];
|
|
},
|
|
|
|
// Refresh a plugin config tab via HTMX
|
|
refreshConfig(pluginId) {
|
|
const container = document.querySelector(`#plugin-config-${pluginId}`);
|
|
if (container && window.htmx) {
|
|
htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
|
|
target: container,
|
|
swap: 'innerHTML'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// Alpine.js app function - full implementation
|
|
function app() {
|
|
// If Alpine is already initialized, get the current component and enhance it
|
|
let baseComponent = {};
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
baseComponent = appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
|
|
const fullImplementation = {
|
|
activeTab: (function() {
|
|
// Auto-open WiFi tab when in AP mode (192.168.4.x)
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
return isAPMode ? 'wifi' : 'overview';
|
|
})(),
|
|
mobileNavOpen: false,
|
|
installedPlugins: [],
|
|
|
|
init() {
|
|
// Prevent multiple initializations
|
|
if (this._initialized) {
|
|
return;
|
|
}
|
|
this._initialized = true;
|
|
|
|
// Load plugins on page load so tabs are available on any page, regardless of active tab
|
|
// First check if plugins are already in window.installedPlugins (from plugins_manager.js)
|
|
if (typeof window.installedPlugins !== 'undefined' && Array.isArray(window.installedPlugins) && window.installedPlugins.length > 0) {
|
|
this.installedPlugins = window.installedPlugins;
|
|
debugLog('Initialized installedPlugins from global:', this.installedPlugins.length);
|
|
// Ensure tabs are updated immediately
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
} else if (!this.installedPlugins || this.installedPlugins.length === 0) {
|
|
// Load plugins asynchronously, but ensure tabs update when done
|
|
this.loadInstalledPlugins().then(() => {
|
|
// Ensure tabs are updated after loading
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
}).catch(err => {
|
|
console.error('Error loading plugins in init:', err);
|
|
// Still try to update tabs in case some plugins are available
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
});
|
|
} else {
|
|
// Plugins already loaded, just update tabs
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
}
|
|
|
|
// Ensure content loads for the active tab
|
|
this.$watch('activeTab', (newTab, oldTab) => {
|
|
// Update plugin tab states when activeTab changes
|
|
if (typeof this.updatePluginTabStates === 'function') {
|
|
this.updatePluginTabStates();
|
|
}
|
|
// Screen readers announce the current tab (covers every
|
|
// path that changes tabs: clicks, search deep links,
|
|
// the getting-started checklist)
|
|
if (typeof window.updateNavAriaCurrent === 'function') {
|
|
window.updateNavAriaCurrent(newTab);
|
|
}
|
|
// Floating preview hides on Overview (full preview
|
|
// there), reappears per its saved state elsewhere
|
|
if (typeof window.updateFloatingPreviewVisibility === 'function') {
|
|
window.updateFloatingPreviewVisibility(newTab);
|
|
}
|
|
// Trigger content load when tab changes
|
|
this.$nextTick(() => {
|
|
this.loadTabContent(newTab);
|
|
});
|
|
});
|
|
|
|
// Load initial tab content
|
|
this.$nextTick(() => {
|
|
this.loadTabContent(this.activeTab);
|
|
if (typeof window.updateNavAriaCurrent === 'function') {
|
|
window.updateNavAriaCurrent(this.activeTab);
|
|
}
|
|
});
|
|
|
|
// Listen for plugin updates from pluginManager
|
|
document.addEventListener('pluginsUpdated', (event) => {
|
|
debugLog('Received pluginsUpdated event:', event.detail.plugins.length, 'plugins');
|
|
this.installedPlugins = event.detail.plugins;
|
|
this.updatePluginTabs();
|
|
});
|
|
|
|
// Also listen for direct window.installedPlugins changes
|
|
// Store the actual value in a private property to avoid infinite loops
|
|
let _installedPluginsValue = this.installedPlugins || [];
|
|
|
|
// Only define the property if it doesn't already exist or if it's configurable
|
|
const existingDescriptor = Object.getOwnPropertyDescriptor(window, 'installedPlugins');
|
|
if (!existingDescriptor || existingDescriptor.configurable) {
|
|
// Delete existing property if it exists and is configurable
|
|
if (existingDescriptor) {
|
|
delete window.installedPlugins;
|
|
}
|
|
|
|
Object.defineProperty(window, 'installedPlugins', {
|
|
set: (value) => {
|
|
const newPlugins = value || [];
|
|
const oldIds = (_installedPluginsValue || []).map(p => p.id).sort().join(',');
|
|
const newIds = newPlugins.map(p => p.id).sort().join(',');
|
|
|
|
// Always take the new list — same-ID updates
|
|
// still carry changed metadata/enabled state.
|
|
_installedPluginsValue = newPlugins;
|
|
this.installedPlugins = newPlugins;
|
|
// Only rebuild the tab row when the ID set
|
|
// actually changed.
|
|
if (oldIds !== newIds) {
|
|
debugLog('window.installedPlugins changed:', newPlugins.length, 'plugins');
|
|
this.updatePluginTabs();
|
|
}
|
|
},
|
|
get: () => _installedPluginsValue,
|
|
configurable: true // Allow redefinition if needed
|
|
});
|
|
} else {
|
|
// Property already exists and is not configurable, just update the value
|
|
if (typeof window.installedPlugins !== 'undefined') {
|
|
_installedPluginsValue = window.installedPlugins;
|
|
}
|
|
}
|
|
|
|
},
|
|
|
|
loadTabContent(tab) {
|
|
const contentEl = document.getElementById(tab + '-content');
|
|
// data-loaded: already fetched. data-loading: a fetch is queued or in
|
|
// flight. Both guard against re-entry so a panel loads exactly once, even
|
|
// if the tab is reopened before an in-progress (or polling) load settles.
|
|
if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return;
|
|
const url = contentEl.getAttribute('hx-get');
|
|
if (!url) return;
|
|
|
|
contentEl.setAttribute('data-loading', 'true');
|
|
|
|
// htmx.ajax issues the request and swaps the response into the panel
|
|
// directly, so it works even before htmx has wired up the element's
|
|
// hx-trigger listeners. data-loaded is stamped on success so the panel
|
|
// loads once; the activeTab check drops loads for a tab the user navigated
|
|
// away from while htmx was still loading (avoids fetching hidden panels).
|
|
const swap = contentEl.getAttribute('hx-swap') || 'innerHTML';
|
|
const load = () => {
|
|
if (this.activeTab !== tab || contentEl.hasAttribute('data-loaded')) {
|
|
contentEl.removeAttribute('data-loading');
|
|
return;
|
|
}
|
|
return htmx.ajax('GET', url, { target: contentEl, swap: swap })
|
|
.then(() => contentEl.setAttribute('data-loaded', 'true'))
|
|
.catch(() => {}) // leave unstamped on failure so it can retry
|
|
.finally(() => contentEl.removeAttribute('data-loading'));
|
|
};
|
|
|
|
if (typeof htmx !== 'undefined') {
|
|
load();
|
|
return;
|
|
}
|
|
|
|
// htmx is loaded from a CDN and may not be ready yet. Poll until it is,
|
|
// then load; if it never arrives, fall back to a direct fetch.
|
|
let tries = 0;
|
|
const timer = setInterval(() => {
|
|
if (typeof htmx !== 'undefined') {
|
|
clearInterval(timer);
|
|
load();
|
|
} else if (++tries > 100) { // ~10s
|
|
clearInterval(timer);
|
|
contentEl.removeAttribute('data-loading');
|
|
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
|
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
|
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
|
else if (tab === 'tools') {
|
|
fetch('/v3/partials/tools')
|
|
.then(r => {
|
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
|
return r.text();
|
|
})
|
|
.then(html => {
|
|
contentEl.innerHTML = html;
|
|
contentEl.setAttribute('data-loaded', 'true');
|
|
if (window.Alpine) window.Alpine.initTree(contentEl);
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load tools content:', err);
|
|
contentEl.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Tools. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
}, 100);
|
|
},
|
|
|
|
async loadInstalledPlugins() {
|
|
// If pluginManager exists (plugins.html is loaded), delegate to it
|
|
if (window.pluginManager) {
|
|
debugLog('[FULL] Delegating plugin loading to pluginManager...');
|
|
await window.pluginManager.loadInstalledPlugins();
|
|
// pluginManager should set window.installedPlugins, so update our component
|
|
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
|
this.installedPlugins = window.installedPlugins;
|
|
debugLog('[FULL] Updated component plugins from window.installedPlugins:', this.installedPlugins.length);
|
|
}
|
|
this.updatePluginTabs();
|
|
return;
|
|
}
|
|
|
|
// Otherwise, load plugins directly (fallback for when plugins.html isn't loaded)
|
|
try {
|
|
debugLog('[FULL] Loading installed plugins directly...');
|
|
const data = await getInstalledPluginsSafe();
|
|
|
|
if (data.status === 'success') {
|
|
this.installedPlugins = data.data.plugins || [];
|
|
// Also update window.installedPlugins for consistency
|
|
window.installedPlugins = this.installedPlugins;
|
|
debugLog(`[FULL] Loaded ${this.installedPlugins.length} plugins:`, this.installedPlugins.map(p => p.id));
|
|
|
|
// Debug: Log enabled status for each plugin
|
|
this.installedPlugins.forEach(plugin => {
|
|
debugLog(`[DEBUG Alpine] Plugin ${plugin.id}: enabled=${plugin.enabled} (type: ${typeof plugin.enabled})`);
|
|
});
|
|
|
|
this.updatePluginTabs();
|
|
} else {
|
|
console.error('[FULL] Failed to load plugins:', data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('[FULL] Error loading installed plugins:', error);
|
|
}
|
|
},
|
|
|
|
updatePluginTabs(retryCount = 0) {
|
|
debugLog('[FULL] updatePluginTabs called (retryCount:', retryCount, ')');
|
|
const maxRetries = 5;
|
|
|
|
// Debounce: Clear any pending update
|
|
if (this._updatePluginTabsTimeout) {
|
|
clearTimeout(this._updatePluginTabsTimeout);
|
|
}
|
|
|
|
// For first call or retries, execute immediately to ensure tabs appear quickly
|
|
if (retryCount === 0) {
|
|
// First call - execute immediately, then debounce subsequent calls
|
|
this._doUpdatePluginTabs(retryCount);
|
|
} else {
|
|
// Retry - execute immediately
|
|
this._doUpdatePluginTabs(retryCount);
|
|
}
|
|
},
|
|
|
|
_doUpdatePluginTabs(retryCount = 0) {
|
|
const maxRetries = 5;
|
|
|
|
// Use component's installedPlugins first (most up-to-date), then global, then empty array
|
|
const pluginsToShow = (this.installedPlugins && this.installedPlugins.length > 0)
|
|
? this.installedPlugins
|
|
: (window.installedPlugins || []);
|
|
|
|
debugLog('[FULL] _doUpdatePluginTabs called with:', pluginsToShow.length, 'plugins (attempt', retryCount + 1, ')');
|
|
debugLog('[FULL] Plugin sources:', {
|
|
componentPlugins: this.installedPlugins?.length || 0,
|
|
windowPlugins: window.installedPlugins?.length || 0,
|
|
using: pluginsToShow.length > 0 ? (this.installedPlugins?.length > 0 ? 'component' : 'window') : 'none'
|
|
});
|
|
|
|
// Check if plugin list actually changed by comparing IDs
|
|
const currentPluginIds = pluginsToShow.map(p => p.id).sort().join(',');
|
|
const lastRenderedIds = (this._lastRenderedPluginIds || '');
|
|
|
|
// Only skip if we have plugins and they match (don't skip if both are empty)
|
|
if (currentPluginIds === lastRenderedIds && retryCount === 0 && currentPluginIds.length > 0) {
|
|
// Plugin list hasn't changed, skip update
|
|
debugLog('[FULL] Plugin list unchanged, skipping update');
|
|
return;
|
|
}
|
|
|
|
// If we have no plugins and haven't rendered anything yet, still try to render (might be first load)
|
|
if (pluginsToShow.length === 0 && retryCount === 0) {
|
|
debugLog('[FULL] No plugins to show, but will retry in case they load...');
|
|
if (retryCount < maxRetries) {
|
|
setTimeout(() => {
|
|
this._doUpdatePluginTabs(retryCount + 1);
|
|
}, 500);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Store the current plugin IDs for next comparison
|
|
this._lastRenderedPluginIds = currentPluginIds;
|
|
|
|
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
|
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
|
|
|
debugLog('[FULL] Plugin tabs elements:', {
|
|
pluginTabsRow: !!pluginTabsRow,
|
|
pluginTabsNav: !!pluginTabsNav,
|
|
bodyExists: !!document.body,
|
|
installedPlugins: pluginsToShow.length,
|
|
pluginIds: pluginsToShow.map(p => p.id)
|
|
});
|
|
|
|
if (!pluginTabsRow || !pluginTabsNav) {
|
|
if (retryCount < maxRetries) {
|
|
console.warn('[FULL] Plugin tabs container not found, retrying in 500ms... (attempt', retryCount + 1, 'of', maxRetries, ')');
|
|
setTimeout(() => {
|
|
this._doUpdatePluginTabs(retryCount + 1);
|
|
}, 500);
|
|
} else {
|
|
console.error('[FULL] Plugin tabs container not found after maximum retries. Elements:', {
|
|
pluginTabsRow: document.getElementById('plugin-tabs-row'),
|
|
pluginTabsNav: document.getElementById('plugin-tabs-row')?.querySelector('nav'),
|
|
allNavs: document.querySelectorAll('nav').length
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
debugLog(`[FULL] Updating plugin tabs for ${pluginsToShow.length} plugins`);
|
|
|
|
// Always show the plugin tabs row (Plugin Manager should always be available)
|
|
debugLog('[FULL] Ensuring plugin tabs row is visible');
|
|
pluginTabsRow.style.display = 'block';
|
|
|
|
// Clear existing plugin tabs (except the Plugin Manager tab)
|
|
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
debugLog(`[FULL] Removing ${existingTabs.length} existing plugin tabs`);
|
|
existingTabs.forEach(tab => tab.remove());
|
|
|
|
// Add tabs for each installed plugin
|
|
debugLog('[FULL] Adding tabs for plugins:', pluginsToShow.map(p => p.id));
|
|
pluginsToShow.forEach(plugin => {
|
|
const tabButton = document.createElement('button');
|
|
tabButton.type = 'button';
|
|
tabButton.setAttribute('data-plugin-id', plugin.id);
|
|
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
|
|
tabButton.onclick = () => {
|
|
this.activeTab = plugin.id;
|
|
if (typeof this.updatePluginTabStates === 'function') {
|
|
this.updatePluginTabStates();
|
|
}
|
|
};
|
|
// Build the <i class="..."> + label as DOM nodes so a
|
|
// hostile plugin.icon (e.g. containing a quote) can't
|
|
// break out of the attribute. escapeHtml now escapes
|
|
// quotes too, but setting the property directly cannot
|
|
// be got wrong at all, so it stays.
|
|
const iconEl = document.createElement('i');
|
|
iconEl.className = plugin.icon || 'fas fa-puzzle-piece';
|
|
const labelNode = document.createTextNode(plugin.name || plugin.id);
|
|
tabButton.replaceChildren(iconEl, labelNode);
|
|
|
|
// Insert before the closing </nav> tag
|
|
pluginTabsNav.appendChild(tabButton);
|
|
debugLog('[FULL] Added tab for plugin:', plugin.id);
|
|
});
|
|
|
|
debugLog('[FULL] Plugin tabs update completed. Total tabs:', pluginTabsNav.querySelectorAll('.plugin-tab').length);
|
|
},
|
|
|
|
updatePluginTabStates() {
|
|
// Update active state of all plugin tabs when activeTab changes
|
|
const pluginTabsNav = document.getElementById('plugin-tabs-row')?.querySelector('nav');
|
|
if (!pluginTabsNav) return;
|
|
|
|
const pluginTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
pluginTabs.forEach(tab => {
|
|
const pluginId = tab.getAttribute('data-plugin-id');
|
|
if (pluginId && this.activeTab === pluginId) {
|
|
tab.classList.add('nav-tab-active');
|
|
} else {
|
|
tab.classList.remove('nav-tab-active');
|
|
}
|
|
});
|
|
},
|
|
|
|
showNotification(message, type = 'info') {
|
|
// Use global notification widget
|
|
if (typeof window.showNotification === 'function') {
|
|
window.showNotification(message, type);
|
|
} else {
|
|
debugLog(`[${type.toUpperCase()}]`, message);
|
|
}
|
|
},
|
|
|
|
// Quotes too, so the result is safe inside a quoted attribute
|
|
// value -- the textContent/innerHTML round-trip alone only
|
|
// escapes &, < and >.
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
},
|
|
|
|
async refreshPlugins() {
|
|
await this.loadInstalledPlugins();
|
|
await this.searchPluginStore();
|
|
this.showNotification('Plugin list refreshed', 'success');
|
|
},
|
|
|
|
|
|
|
|
async loadPluginConfig(pluginId) {
|
|
debugLog('Loading config for plugin:', pluginId);
|
|
this.loading = true;
|
|
|
|
try {
|
|
// Load config, schema, and installed plugins (for web_ui_actions) in parallel
|
|
// Use batched API if available for better performance
|
|
let configData, schemaData, pluginsData;
|
|
|
|
if (window.PluginAPI && window.PluginAPI.batch) {
|
|
// PluginAPI.batch returns already-parsed JSON objects
|
|
try {
|
|
const results = await window.PluginAPI.batch([
|
|
{endpoint: `/plugins/config?plugin_id=${pluginId}`, method: 'GET'},
|
|
{endpoint: `/plugins/schema?plugin_id=${pluginId}`, method: 'GET'},
|
|
{endpoint: '/plugins/installed', method: 'GET'}
|
|
]);
|
|
[configData, schemaData, pluginsData] = results;
|
|
} catch (batchError) {
|
|
console.error('Batch API request failed, falling back to individual requests:', batchError);
|
|
// Fall back to individual requests
|
|
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
|
|
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
|
|
]);
|
|
configData = configResponse;
|
|
schemaData = schemaResponse;
|
|
pluginsData = pluginsResponse;
|
|
}
|
|
} else {
|
|
// Direct fetch returns Response objects that need parsing
|
|
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
|
|
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
|
|
]);
|
|
configData = configResponse;
|
|
schemaData = schemaResponse;
|
|
pluginsData = pluginsResponse;
|
|
}
|
|
|
|
if (configData && configData.status === 'success') {
|
|
this.config = configData.data;
|
|
} else {
|
|
console.warn('Config API returned non-success status:', configData);
|
|
// Set defaults if config failed to load
|
|
this.config = { enabled: true, display_duration: 30 };
|
|
}
|
|
|
|
if (schemaData && schemaData.status === 'success') {
|
|
this.schema = schemaData.data.schema || {};
|
|
} else {
|
|
console.warn('Schema API returned non-success status:', schemaData);
|
|
// Set empty schema as fallback
|
|
this.schema = {};
|
|
}
|
|
|
|
// Extract web_ui_actions from installed plugins and update plugin data
|
|
if (pluginsData && pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
|
|
// Update window.installedPlugins with fresh data (includes commit info)
|
|
// The setter will check if data actually changed before updating tabs
|
|
window.installedPlugins = pluginsData.data.plugins;
|
|
// Update Alpine.js app data
|
|
this.installedPlugins = pluginsData.data.plugins;
|
|
|
|
const pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
|
|
this.webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
|
|
debugLog('[DEBUG] Loaded web_ui_actions for', pluginId, ':', this.webUiActions.length, 'actions');
|
|
debugLog('[DEBUG] Updated plugin data with commit info:', pluginInfo ? {
|
|
last_commit: pluginInfo.last_commit,
|
|
branch: pluginInfo.branch,
|
|
last_updated: pluginInfo.last_updated
|
|
} : 'plugin not found');
|
|
} else {
|
|
console.warn('Plugins API returned non-success status:', pluginsData);
|
|
this.webUiActions = [];
|
|
}
|
|
|
|
debugLog('Loaded config, schema, and actions for', pluginId);
|
|
} catch (error) {
|
|
console.error('Error loading plugin config:', error);
|
|
this.config = { enabled: true, display_duration: 30 };
|
|
this.schema = {};
|
|
this.webUiActions = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
// Helper function to get schema property type for a field path
|
|
getSchemaPropertyType(schema, path) {
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
const parts = path.split('.');
|
|
let current = schema.properties;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (current && current[part]) {
|
|
if (i === parts.length - 1) {
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// Helper function to escape CSS selector special characters
|
|
escapeCssSelector(str) {
|
|
if (typeof str !== 'string') {
|
|
str = String(str);
|
|
}
|
|
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
|
|
if (typeof CSS !== 'undefined' && CSS.escape) {
|
|
return CSS.escape(str);
|
|
}
|
|
// Fallback to regex-based escaping for older browsers
|
|
// First, handle leading digits and whitespace (must be done before regex)
|
|
let escaped = str;
|
|
let hasLeadingHexEscape = false;
|
|
if (escaped.length > 0) {
|
|
const firstChar = escaped[0];
|
|
const firstCode = firstChar.charCodeAt(0);
|
|
|
|
// Escape leading digit (0-9: U+0030-U+0039)
|
|
if (firstCode >= 0x30 && firstCode <= 0x39) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
// Escape leading whitespace (space: U+0020, tab: U+0009, etc.)
|
|
else if (/\s/.test(firstChar)) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
}
|
|
|
|
// Escape special characters
|
|
escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
|
|
|
// Escape internal spaces (replace spaces with \ ), but preserve space in hex escape
|
|
if (hasLeadingHexEscape) {
|
|
// Skip the first 6 characters (e.g., "\0030 ") when replacing spaces
|
|
escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ ');
|
|
} else {
|
|
escaped = escaped.replace(/ /g, '\\ ');
|
|
}
|
|
|
|
return escaped;
|
|
},
|
|
|
|
async savePluginConfig(pluginId, event) {
|
|
try {
|
|
// Get the form element for this plugin
|
|
const form = event ? event.target : null;
|
|
if (!form) {
|
|
throw new Error('Form element not found');
|
|
}
|
|
const formData = new FormData(form);
|
|
const schema = this.schema || {};
|
|
|
|
// First, collect all checkbox states (including unchecked ones)
|
|
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
|
|
const flatConfig = {};
|
|
|
|
// Process all form elements to capture all field states
|
|
for (let i = 0; i < form.elements.length; i++) {
|
|
const element = form.elements[i];
|
|
const name = element.name;
|
|
|
|
// Skip elements without names or submit buttons
|
|
if (!name || element.type === 'submit' || element.type === 'button') {
|
|
continue;
|
|
}
|
|
|
|
// Handle checkboxes explicitly (both checked and unchecked)
|
|
if (element.type === 'checkbox') {
|
|
// Check if this is a checkbox group (name ends with [])
|
|
if (name.endsWith('[]')) {
|
|
const baseName = name.slice(0, -2); // Remove '[]' suffix
|
|
if (!flatConfig[baseName]) {
|
|
flatConfig[baseName] = [];
|
|
}
|
|
if (element.checked) {
|
|
flatConfig[baseName].push(element.value);
|
|
}
|
|
} else {
|
|
// Regular checkbox (boolean)
|
|
flatConfig[name] = element.checked;
|
|
}
|
|
}
|
|
// Handle radio buttons
|
|
else if (element.type === 'radio') {
|
|
if (element.checked) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
// Handle select elements (including multi-select)
|
|
else if (element.tagName === 'SELECT') {
|
|
if (element.multiple) {
|
|
// Multi-select: get all selected options
|
|
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
|
|
flatConfig[name] = selectedValues;
|
|
} else {
|
|
// Single select: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
// Handle textarea
|
|
else if (element.tagName === 'TEXTAREA') {
|
|
// Textarea: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now process FormData for other field types
|
|
for (const [key, value] of formData.entries()) {
|
|
// Skip checkboxes - we already handled them above
|
|
// Use querySelector to reliably find element by name (handles dot notation)
|
|
const escapedKey = this.escapeCssSelector(key);
|
|
const element = form.querySelector(`[name="${escapedKey}"]`);
|
|
if (element && element.type === 'checkbox') {
|
|
// Also skip checkbox groups (name ends with [])
|
|
if (key.endsWith('[]')) {
|
|
continue; // Already processed
|
|
}
|
|
continue; // Already processed
|
|
}
|
|
// Skip multi-select - we already handled them above
|
|
if (element && element.tagName === 'SELECT' && element.multiple) {
|
|
continue; // Already processed
|
|
}
|
|
|
|
// Get schema property type if available
|
|
const propSchema = this.getSchemaPropertyType(schema, key);
|
|
const propType = propSchema ? propSchema.type : null;
|
|
|
|
// Handle based on schema type or field name patterns
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array in hidden input)
|
|
if (propSchema && propSchema['x-widget'] === 'file-upload') {
|
|
try {
|
|
// Unescape HTML entities that were escaped when setting the value
|
|
let unescapedValue = value;
|
|
if (typeof value === 'string') {
|
|
// Reverse the HTML escaping: " -> ", ' -> ', & -> &
|
|
unescapedValue = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&');
|
|
}
|
|
|
|
// Try to parse as JSON
|
|
const jsonValue = JSON.parse(unescapedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
debugLog(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
|
|
// Not valid JSON, use empty array or try comma-separated
|
|
if (value && value.trim()) {
|
|
// Try to unescape and parse again
|
|
try {
|
|
const unescaped = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&');
|
|
const jsonValue = JSON.parse(unescaped);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e2) {
|
|
// If still fails, try comma-separated or empty array
|
|
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
|
|
flatConfig[key] = arrayValue.length > 0 ? arrayValue : [];
|
|
}
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[key] = arrayValue;
|
|
}
|
|
} else if (propType === 'integer' || (Array.isArray(propType) && propType.includes('integer'))) {
|
|
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
|
|
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
|
|
flatConfig[key] = ''; // Send empty string, backend will normalize to null
|
|
} else {
|
|
const numValue = parseInt(value, 10);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
}
|
|
} else if (propType === 'number' || (Array.isArray(propType) && propType.includes('number'))) {
|
|
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
|
|
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
|
|
flatConfig[key] = ''; // Send empty string, backend will normalize to null
|
|
} else {
|
|
const numValue = parseFloat(value);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
}
|
|
} else if (propType === 'boolean') {
|
|
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
|
|
flatConfig[key] = value === 'on' || value === 'true' || value === true;
|
|
} else {
|
|
// String or other types
|
|
// Check if it's a number field by name pattern (fallback if no schema)
|
|
if (!propType && (key.includes('duration') || key.includes('interval') ||
|
|
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
|
|
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
|
|
const numValue = parseFloat(value);
|
|
if (!isNaN(numValue)) {
|
|
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes using schema (if available)
|
|
if (schema && schema.properties) {
|
|
const collectBooleanFields = (props, prefix = '') => {
|
|
const boolFields = [];
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
|
|
}
|
|
}
|
|
return boolFields;
|
|
};
|
|
|
|
const allBoolFields = collectBooleanFields(schema.properties);
|
|
allBoolFields.forEach(key => {
|
|
// Only set to false if the field is completely missing from flatConfig
|
|
// Don't override existing false values - they're explicitly set by the user
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const dotToNested = (obj) => {
|
|
const result = {};
|
|
for (const key in obj) {
|
|
const parts = key.split('.');
|
|
let current = result;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) {
|
|
current[parts[i]] = {};
|
|
}
|
|
current = current[parts[i]];
|
|
}
|
|
current[parts[parts.length - 1]] = obj[key];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const config = dotToNested(flatConfig);
|
|
|
|
// Save to backend
|
|
const response = await fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
config: config
|
|
})
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (e) {
|
|
console.error('Failed to parse JSON response:', e);
|
|
console.error('Response status:', response.status, response.statusText);
|
|
console.error('Response text:', await response.text());
|
|
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
debugLog('Response status:', response.status, 'Response OK:', response.ok);
|
|
debugLog('Response data:', JSON.stringify(data, null, 2));
|
|
|
|
if (!response.ok || data.status !== 'success') {
|
|
let errorMessage = data.message || 'Failed to save configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
console.error('Validation errors:', data.validation_errors);
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
if (data.config_keys && data.schema_keys) {
|
|
console.error('Config keys sent:', data.config_keys);
|
|
console.error('Schema keys expected:', data.schema_keys);
|
|
const extraKeys = data.config_keys.filter(k => !data.schema_keys.includes(k));
|
|
const missingKeys = data.schema_keys.filter(k => !data.config_keys.includes(k));
|
|
if (extraKeys.length > 0) {
|
|
errorMessage += '\n\nExtra keys (not in schema): ' + extraKeys.join(', ');
|
|
}
|
|
if (missingKeys.length > 0) {
|
|
errorMessage += '\n\nMissing keys (in schema): ' + missingKeys.join(', ');
|
|
}
|
|
}
|
|
this.showNotification(errorMessage, 'error');
|
|
console.error('Config save failed - Full error response:', JSON.stringify(data, null, 2));
|
|
} else {
|
|
this.showNotification('Configuration saved successfully', 'success');
|
|
// Reload plugin config to reflect changes
|
|
await this.loadPluginConfig(pluginId);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving plugin config:', error);
|
|
this.showNotification('Error saving configuration: ' + error.message, 'error');
|
|
}
|
|
},
|
|
|
|
formatCommitInfo(commit, branch) {
|
|
// Handle null, undefined, or empty string
|
|
const commitStr = (commit && String(commit).trim()) || '';
|
|
const branchStr = (branch && String(branch).trim()) || '';
|
|
|
|
if (!commitStr && !branchStr) return 'Unknown';
|
|
|
|
const shortCommit = commitStr.length >= 7 ? commitStr.substring(0, 7) : commitStr;
|
|
|
|
if (branchStr && shortCommit) {
|
|
return `${branchStr} · ${shortCommit}`;
|
|
}
|
|
if (branchStr) {
|
|
return branchStr;
|
|
}
|
|
if (shortCommit) {
|
|
return shortCommit;
|
|
}
|
|
return 'Unknown';
|
|
},
|
|
|
|
formatDateInfo(dateString) {
|
|
// Handle null, undefined, or empty string
|
|
if (!dateString || !String(dateString).trim()) return 'Unknown';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
// Check if date is valid
|
|
if (isNaN(date.getTime())) {
|
|
return 'Unknown';
|
|
}
|
|
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 1) {
|
|
return 'Today';
|
|
} else if (diffDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else if (diffDays < 30) {
|
|
const weeks = Math.floor(diffDays / 7);
|
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
|
} else {
|
|
// Return formatted date for older items
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
} catch (e) {
|
|
console.error('Error formatting date:', e, dateString);
|
|
return 'Unknown';
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update window.app to return full implementation
|
|
window.app = function() {
|
|
return fullImplementation;
|
|
};
|
|
|
|
// If Alpine is already initialized, update the existing component immediately
|
|
if (window.Alpine) {
|
|
// Use requestAnimationFrame for immediate execution without blocking
|
|
requestAnimationFrame(() => {
|
|
if (window._appEnhanced) return;
|
|
window._appEnhanced = true;
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
const defaultTab = isAPMode ? 'wifi' : 'overview';
|
|
const appElement = document.querySelector('[x-data]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
const existingComponent = appElement._x_dataStack[0];
|
|
// Preserve runtime state that should not be reset
|
|
const preservedPlugins = existingComponent.installedPlugins;
|
|
const preservedTab = existingComponent.activeTab;
|
|
// Replace all properties and methods from full implementation
|
|
Object.keys(fullImplementation).forEach(key => {
|
|
existingComponent[key] = fullImplementation[key];
|
|
});
|
|
// Restore runtime state if non-default
|
|
if (preservedPlugins && preservedPlugins.length > 0) {
|
|
existingComponent.installedPlugins = preservedPlugins;
|
|
}
|
|
if (preservedTab && preservedTab !== defaultTab) {
|
|
existingComponent.activeTab = preservedTab;
|
|
}
|
|
// Call init to load plugins and set up watchers (only if not already initialized)
|
|
if (typeof existingComponent.init === 'function' && !existingComponent._initialized) {
|
|
existingComponent.init();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return fullImplementation;
|
|
}
|
|
|
|
// Make app() available globally
|
|
window.app = app;
|
|
|
|
|
|
// ===== Nested Section Toggle =====
|
|
window.toggleNestedSection = function(sectionId, event) {
|
|
// Prevent event bubbling if event is provided
|
|
if (event) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
}
|
|
|
|
const content = document.getElementById(sectionId);
|
|
const icon = document.getElementById(sectionId + '-icon');
|
|
|
|
if (!content || !icon) {
|
|
console.warn('[toggleNestedSection] Content or icon not found for:', sectionId);
|
|
return;
|
|
}
|
|
|
|
// Check if content is currently collapsed (has 'collapsed' class or display:none)
|
|
const isCollapsed = content.classList.contains('collapsed') ||
|
|
content.style.display === 'none' ||
|
|
(content.style.display === '' && !content.classList.contains('expanded'));
|
|
|
|
if (isCollapsed) {
|
|
// Expand the section
|
|
content.classList.remove('collapsed');
|
|
content.classList.add('expanded');
|
|
content.style.display = 'block';
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// CRITICAL FIX: Use setTimeout to ensure browser has time to layout the element
|
|
// When element goes from display:none to display:block, scrollHeight might be 0
|
|
// We need to wait for the browser to calculate the layout
|
|
setTimeout(() => {
|
|
// Force reflow to ensure transition works
|
|
void content.offsetHeight;
|
|
|
|
// Now measure the actual content height after layout
|
|
const scrollHeight = content.scrollHeight;
|
|
if (scrollHeight > 0) {
|
|
content.style.maxHeight = scrollHeight + 'px';
|
|
} else {
|
|
// Fallback: if scrollHeight is still 0, try measuring again after a brief delay
|
|
setTimeout(() => {
|
|
const retryHeight = content.scrollHeight;
|
|
content.style.maxHeight = retryHeight > 0 ? retryHeight + 'px' : '500px';
|
|
}, 10);
|
|
}
|
|
}, 10);
|
|
|
|
icon.classList.remove('fa-chevron-right');
|
|
icon.classList.add('fa-chevron-down');
|
|
|
|
// After animation completes, remove max-height constraint to allow natural expansion
|
|
setTimeout(() => {
|
|
if (content.classList.contains('expanded') && !content.classList.contains('collapsed')) {
|
|
content.style.maxHeight = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
}, 320); // Slightly longer than transition duration
|
|
} else {
|
|
// Collapse the section
|
|
content.classList.add('collapsed');
|
|
content.classList.remove('expanded');
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// Set max-height to current scroll height first (required for smooth animation)
|
|
const currentHeight = content.scrollHeight;
|
|
content.style.maxHeight = currentHeight + 'px';
|
|
|
|
// Force reflow to apply the height
|
|
void content.offsetHeight;
|
|
|
|
// Then animate to 0
|
|
setTimeout(() => {
|
|
content.style.maxHeight = '0';
|
|
}, 10);
|
|
|
|
// Hide after transition completes
|
|
setTimeout(() => {
|
|
if (content.classList.contains('collapsed')) {
|
|
content.style.display = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
}, 320); // Match the CSS transition duration + small buffer
|
|
|
|
icon.classList.remove('fa-chevron-down');
|
|
icon.classList.add('fa-chevron-right');
|
|
}
|
|
};
|
|
|
|
// ===== Display Preview Functions (from v2) =====
|
|
|
|
function updateDisplayPreview(data) {
|
|
const preview = document.getElementById('displayPreview');
|
|
const stage = document.getElementById('previewStage');
|
|
const img = document.getElementById('displayImage');
|
|
const canvas = document.getElementById('gridOverlay');
|
|
const ledCanvas = document.getElementById('ledCanvas');
|
|
const placeholder = document.getElementById('displayPlaceholder');
|
|
|
|
// Always cache the latest frame so the floating preview can show
|
|
// something the moment it's opened — SSE only sends frames when
|
|
// the display CHANGES, so a late subscriber would otherwise stare
|
|
// at an empty panel until the next change.
|
|
if (data.image) {
|
|
window._lastPreviewFrame = data.image;
|
|
}
|
|
// Feed the floating mini preview (lives in base.html, present on
|
|
// every tab) before the overview-only guard below.
|
|
const floatImg = document.getElementById('floating-preview-img');
|
|
const floatPanel = document.getElementById('floating-preview');
|
|
if (floatImg && floatPanel && floatPanel.style.display !== 'none' && data.image) {
|
|
floatImg.src = `data:image/png;base64,${data.image}`;
|
|
}
|
|
|
|
if (!stage || !img || !placeholder) return; // Not on overview page
|
|
|
|
if (data.image) {
|
|
// Show stage
|
|
placeholder.style.display = 'none';
|
|
stage.style.display = 'inline-block';
|
|
|
|
// Current scale from slider
|
|
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
|
|
|
|
// Update image and meta label. Size everything from the PNG's
|
|
// OWN dimensions (naturalWidth/Height) once it has loaded —
|
|
// not from the server-reported config dimensions. If the
|
|
// config disagrees with what the display service actually
|
|
// renders (stale config, service not yet restarted), sizing
|
|
// from config stretches the image at a fractional ratio and
|
|
// the preview looks blurry until the scale slider forces a
|
|
// re-render. The slider path always used natural dimensions;
|
|
// now both paths do.
|
|
img.style.imageRendering = 'pixelated';
|
|
img.onload = () => {
|
|
const nw = img.naturalWidth || data.width || 128;
|
|
const nh = img.naturalHeight || data.height || 64;
|
|
const width = nw * scale;
|
|
const height = nh * scale;
|
|
img.style.width = width + 'px';
|
|
img.style.height = height + 'px';
|
|
ledCanvas.width = width;
|
|
ledCanvas.height = height;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const meta = document.getElementById('previewMeta');
|
|
if (meta) {
|
|
meta.textContent = `${nw} x ${nh} @ ${scale}x`;
|
|
}
|
|
drawGrid(canvas, nw, nh, scale);
|
|
renderLedDots();
|
|
};
|
|
img.src = `data:image/png;base64,${data.image}`;
|
|
} else {
|
|
stage.style.display = 'none';
|
|
placeholder.style.display = 'block';
|
|
placeholder.innerHTML = `<div class="text-center text-gray-400 py-8">
|
|
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
|
|
<p>No display data available</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function renderLedDots() {
|
|
const ledCanvas = document.getElementById('ledCanvas');
|
|
const img = document.getElementById('displayImage');
|
|
const toggle = document.getElementById('toggleLedDots');
|
|
|
|
if (!ledCanvas || !img || !toggle) {
|
|
return;
|
|
}
|
|
|
|
const show = toggle.checked;
|
|
|
|
if (!show) {
|
|
// LED mode OFF: Show image, hide canvas
|
|
img.style.visibility = 'visible';
|
|
ledCanvas.style.display = 'none';
|
|
const ctx = ledCanvas.getContext('2d');
|
|
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
return;
|
|
}
|
|
|
|
// LED mode ON: Hide image (but keep layout space), show only dots on canvas
|
|
img.style.visibility = 'hidden';
|
|
ledCanvas.style.display = 'block';
|
|
|
|
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
|
|
const fillPct = parseInt(document.getElementById('dotFillRange')?.value || '75');
|
|
const dotRadius = Math.max(1, Math.floor((scale * fillPct) / 200)); // radius in px
|
|
|
|
const ctx = ledCanvas.getContext('2d', { willReadFrequently: true });
|
|
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
|
|
// Create an offscreen canvas to sample pixel colors
|
|
const off = document.createElement('canvas');
|
|
const logicalWidth = Math.floor(ledCanvas.width / scale);
|
|
const logicalHeight = Math.floor(ledCanvas.height / scale);
|
|
off.width = logicalWidth;
|
|
off.height = logicalHeight;
|
|
const offCtx = off.getContext('2d', { willReadFrequently: true });
|
|
|
|
// Draw the current image scaled down to logical LEDs to sample colors
|
|
try {
|
|
offCtx.drawImage(img, 0, 0, logicalWidth, logicalHeight);
|
|
} catch (e) {
|
|
console.error('Failed to draw image to offscreen canvas:', e);
|
|
return;
|
|
}
|
|
|
|
// Fill canvas with black background (LED matrix bezel)
|
|
ctx.fillStyle = 'rgb(0, 0, 0)';
|
|
ctx.fillRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
|
|
// Read the whole frame once instead of one getImageData call per
|
|
// pixel (a 192x48 panel would otherwise issue ~9,200 calls per
|
|
// frame — noticeably heavy on phones).
|
|
let frame;
|
|
try {
|
|
frame = offCtx.getImageData(0, 0, logicalWidth, logicalHeight).data;
|
|
} catch (e) {
|
|
console.error('Failed to read offscreen canvas pixels:', e);
|
|
return;
|
|
}
|
|
|
|
// Draw circular dots for each LED pixel
|
|
let drawn = 0;
|
|
for (let y = 0; y < logicalHeight; y++) {
|
|
for (let x = 0; x < logicalWidth; x++) {
|
|
const i = (y * logicalWidth + x) * 4;
|
|
const r = frame[i], g = frame[i + 1], b = frame[i + 2], a = frame[i + 3];
|
|
|
|
// Skip fully transparent or black pixels to reduce overdraw
|
|
if (a === 0 || (r|g|b) === 0) continue;
|
|
|
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
|
const cx = Math.floor(x * scale + scale / 2);
|
|
const cy = Math.floor(y * scale + scale / 2);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, dotRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
drawn++;
|
|
}
|
|
}
|
|
|
|
// If nothing was drawn (e.g., image not ready), hide overlay to show base image
|
|
if (drawn === 0) {
|
|
ledCanvas.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function drawGrid(canvas, pixelWidth, pixelHeight, scale) {
|
|
const toggle = document.getElementById('toggleGrid');
|
|
if (!toggle || !toggle.checked) {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
return;
|
|
}
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let x = 0; x <= pixelWidth; x++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * scale, 0);
|
|
ctx.lineTo(x * scale, pixelHeight * scale);
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (let y = 0; y <= pixelHeight; y++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * scale);
|
|
ctx.lineTo(pixelWidth * scale, y * scale);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function takeScreenshot() {
|
|
const img = document.getElementById('displayImage');
|
|
if (img && img.src) {
|
|
const link = document.createElement('a');
|
|
link.download = `led_matrix_${new Date().getTime()}.png`;
|
|
link.href = img.src;
|
|
link.click();
|
|
}
|
|
}
|
|
|
|
// ===== Plugin Management Functions =====
|
|
|
|
// Make togglePluginFromTab global so Alpine.js can access it
|
|
window.togglePluginFromTab = async function(pluginId, enabled) {
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/toggle', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, enabled })
|
|
});
|
|
const data = await response.json();
|
|
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
// Update the plugin in window.installedPlugins
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = enabled;
|
|
}
|
|
}
|
|
|
|
// Refresh the plugin list to ensure both management page and config page stay in sync
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
} else {
|
|
// Revert the toggle if API call failed
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
showNotification('Error toggling plugin: ' + error.message, 'error');
|
|
// Revert on error
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to get schema property type for a field path
|
|
function getSchemaPropertyType(schema, path) {
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
const parts = path.split('.');
|
|
let current = schema.properties;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (current && current[part]) {
|
|
if (i === parts.length - 1) {
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Helper function to escape CSS selector special characters
|
|
function escapeCssSelector(str) {
|
|
if (typeof str !== 'string') {
|
|
str = String(str);
|
|
}
|
|
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
|
|
if (typeof CSS !== 'undefined' && CSS.escape) {
|
|
return CSS.escape(str);
|
|
}
|
|
// Fallback to regex-based escaping for older browsers
|
|
// First, handle leading digits and whitespace (must be done before regex)
|
|
let escaped = str;
|
|
let hasLeadingHexEscape = false;
|
|
if (escaped.length > 0) {
|
|
const firstChar = escaped[0];
|
|
const firstCode = firstChar.charCodeAt(0);
|
|
|
|
// Escape leading digit (0-9: U+0030-U+0039)
|
|
if (firstCode >= 0x30 && firstCode <= 0x39) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
// Escape leading whitespace (space: U+0020, tab: U+0009, etc.)
|
|
else if (/\s/.test(firstChar)) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
}
|
|
|
|
// Escape special characters
|
|
escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
|
|
|
// Escape internal spaces (replace spaces with \ ), but preserve space in hex escape
|
|
if (hasLeadingHexEscape) {
|
|
// Skip the first 6 characters (e.g., "\0030 ") when replacing spaces
|
|
escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ ');
|
|
} else {
|
|
escaped = escaped.replace(/ /g, '\\ ');
|
|
}
|
|
|
|
return escaped;
|
|
}
|
|
|
|
async function savePluginConfig(pluginId) {
|
|
try {
|
|
debugLog('Saving config for plugin:', pluginId);
|
|
|
|
// Load schema for type detection
|
|
let schema = {};
|
|
try {
|
|
const schemaResponse = await fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`);
|
|
const schemaData = await schemaResponse.json();
|
|
if (schemaData.status === 'success' && schemaData.data.schema) {
|
|
schema = schemaData.data.schema;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not load schema for type detection:', e);
|
|
}
|
|
|
|
// Find the form in the active plugin tab
|
|
// Alpine.js hides/shows elements with display:none, so we look for the currently visible one
|
|
const allForms = document.querySelectorAll('form[x-on\\:submit\\.prevent]');
|
|
debugLog('Found forms:', allForms.length);
|
|
|
|
let form = null;
|
|
for (const f of allForms) {
|
|
const parent = f.closest('[x-show]');
|
|
if (parent && parent.style.display !== 'none' && parent.offsetParent !== null) {
|
|
form = f;
|
|
debugLog('Found visible form');
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!form) {
|
|
throw new Error('Form not found for plugin ' + pluginId);
|
|
}
|
|
|
|
const formData = new FormData(form);
|
|
const flatConfig = {};
|
|
|
|
// First, collect all checkbox states (including unchecked ones)
|
|
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
|
|
for (let i = 0; i < form.elements.length; i++) {
|
|
const element = form.elements[i];
|
|
const name = element.name;
|
|
|
|
// Skip elements without names or submit buttons
|
|
if (!name || element.type === 'submit' || element.type === 'button') {
|
|
continue;
|
|
}
|
|
|
|
// Handle checkboxes explicitly (both checked and unchecked)
|
|
if (element.type === 'checkbox') {
|
|
flatConfig[name] = element.checked;
|
|
}
|
|
// Handle radio buttons
|
|
else if (element.type === 'radio') {
|
|
if (element.checked) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
// Handle select elements (including multi-select)
|
|
else if (element.tagName === 'SELECT') {
|
|
if (element.multiple) {
|
|
// Multi-select: get all selected options
|
|
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
|
|
flatConfig[name] = selectedValues;
|
|
} else {
|
|
// Single select: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
// Handle textarea
|
|
else if (element.tagName === 'TEXTAREA') {
|
|
// Textarea: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now process FormData for other field types
|
|
for (const [key, value] of formData.entries()) {
|
|
// Skip checkboxes - we already handled them above
|
|
// Use querySelector to reliably find element by name (handles dot notation)
|
|
const escapedKey = escapeCssSelector(key);
|
|
const element = form.querySelector(`[name="${escapedKey}"]`);
|
|
if (element && element.type === 'checkbox') {
|
|
continue; // Already processed
|
|
}
|
|
// Skip multi-select - we already handled them above
|
|
if (element && element.tagName === 'SELECT' && element.multiple) {
|
|
continue; // Already processed
|
|
}
|
|
|
|
// Get schema property type if available
|
|
const propSchema = getSchemaPropertyType(schema, key);
|
|
const propType = propSchema ? propSchema.type : null;
|
|
|
|
// Handle based on schema type or field name patterns
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array in hidden input)
|
|
if (propSchema && propSchema['x-widget'] === 'file-upload') {
|
|
try {
|
|
// Unescape HTML entities that were escaped when setting the value
|
|
let unescapedValue = value;
|
|
if (typeof value === 'string') {
|
|
// Reverse the HTML escaping: " -> ", ' -> ', & -> &
|
|
unescapedValue = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&');
|
|
}
|
|
|
|
try {
|
|
const jsonValue = JSON.parse(unescapedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
debugLog(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
// Not valid JSON, use empty array or try comma-separated
|
|
if (value && value.trim()) {
|
|
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
|
|
flatConfig[key] = arrayValue;
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[key] = arrayValue;
|
|
}
|
|
} else if (propType === 'integer') {
|
|
const numValue = parseInt(value, 10);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
} else if (propType === 'number') {
|
|
const numValue = parseFloat(value);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
} else if (propType === 'boolean') {
|
|
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
|
|
flatConfig[key] = value === 'on' || value === 'true' || value === true;
|
|
} else {
|
|
// String or other types
|
|
// Check if it's a number field by name pattern (fallback if no schema)
|
|
if (!propType && (key.includes('duration') || key.includes('interval') ||
|
|
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
|
|
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
|
|
const numValue = parseFloat(value);
|
|
if (!isNaN(numValue)) {
|
|
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes using schema (if available)
|
|
if (schema && schema.properties) {
|
|
const collectBooleanFields = (props, prefix = '') => {
|
|
const boolFields = [];
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
|
|
}
|
|
}
|
|
return boolFields;
|
|
};
|
|
|
|
const allBoolFields = collectBooleanFields(schema.properties);
|
|
allBoolFields.forEach(key => {
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const dotToNested = (obj) => {
|
|
const result = {};
|
|
for (const key in obj) {
|
|
const parts = key.split('.');
|
|
let current = result;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) {
|
|
current[parts[i]] = {};
|
|
}
|
|
current = current[parts[i]];
|
|
}
|
|
current[parts[parts.length - 1]] = obj[key];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const config = dotToNested(flatConfig);
|
|
|
|
debugLog('Saving config for', pluginId, ':', config);
|
|
debugLog('Flat config before nesting:', flatConfig);
|
|
|
|
// Save to backend
|
|
const response = await fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, config })
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (e) {
|
|
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
if (!response.ok || data.status !== 'success') {
|
|
let errorMessage = data.message || 'Failed to save configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
throw new Error(errorMessage);
|
|
} else {
|
|
showNotification(`Configuration saved for ${pluginId}`, 'success');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error saving plugin configuration:', error);
|
|
showNotification('Error saving plugin configuration: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Notification helper function
|
|
// Fix invalid number inputs before form submission
|
|
// This prevents "invalid form control is not focusable" errors
|
|
window.fixInvalidNumberInputs = function(form) {
|
|
if (!form) return;
|
|
const allInputs = form.querySelectorAll('input[type="number"]');
|
|
allInputs.forEach(input => {
|
|
const min = parseFloat(input.getAttribute('min'));
|
|
const max = parseFloat(input.getAttribute('max'));
|
|
const value = parseFloat(input.value);
|
|
|
|
if (!isNaN(value)) {
|
|
if (!isNaN(min) && value < min) {
|
|
input.value = min;
|
|
} else if (!isNaN(max) && value > max) {
|
|
input.value = max;
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// showNotification is provided by notification.js widget
|
|
// This fallback is only used if the widget hasn't loaded yet
|
|
if (typeof window.showNotification !== 'function') {
|
|
window.showNotification = function(message, type = 'info') {
|
|
debugLog(`[${type.toUpperCase()}]`, message);
|
|
const notification = document.createElement('div');
|
|
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
|
|
type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
|
} text-white`;
|
|
notification.textContent = message;
|
|
document.body.appendChild(notification);
|
|
setTimeout(() => { notification.style.opacity = '0'; setTimeout(() => notification.remove(), 500); }, 3000);
|
|
};
|
|
}
|
|
|
|
// Section toggle function - already defined earlier, but ensure it's not overwritten
|
|
// (duplicate definition removed - function is defined in early script block above)
|
|
|
|
// Plugin config handler functions (idempotent initialization)
|
|
if (!window.__pluginConfigHandlersInitialized) {
|
|
window.__pluginConfigHandlersInitialized = true;
|
|
|
|
// Initialize state on window object
|
|
window.pluginConfigRefreshInProgress = window.pluginConfigRefreshInProgress || new Set();
|
|
|
|
// Validate plugin config form and show helpful error messages
|
|
window.validatePluginConfigForm = function(form, pluginId) {
|
|
// Check HTML5 validation
|
|
if (!form.checkValidity()) {
|
|
// Find all invalid fields
|
|
const invalidFields = Array.from(form.querySelectorAll(':invalid'));
|
|
const errors = [];
|
|
let firstInvalidField = null;
|
|
|
|
invalidFields.forEach((field, index) => {
|
|
// Build error message
|
|
let fieldName = field.name || field.id || 'field';
|
|
// Make field name more readable (remove plugin ID prefix, convert dots/underscores)
|
|
fieldName = fieldName.replace(new RegExp('^' + pluginId + '-'), '')
|
|
.replace(/\./g, ' → ')
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, l => l.toUpperCase()); // Capitalize words
|
|
|
|
let errorMsg = field.validationMessage || 'Invalid value';
|
|
|
|
// Get more specific error message based on validation state
|
|
if (field.validity.valueMissing) {
|
|
errorMsg = 'This field is required';
|
|
} else if (field.validity.rangeUnderflow) {
|
|
errorMsg = `Value must be at least ${field.min || 'the minimum'}`;
|
|
} else if (field.validity.rangeOverflow) {
|
|
errorMsg = `Value must be at most ${field.max || 'the maximum'}`;
|
|
} else if (field.validity.stepMismatch) {
|
|
errorMsg = `Value must be a multiple of ${field.step || 1}`;
|
|
} else if (field.validity.typeMismatch) {
|
|
errorMsg = 'Invalid format (e.g., text in number field)';
|
|
} else if (field.validity.patternMismatch) {
|
|
errorMsg = 'Value does not match required pattern';
|
|
} else if (field.validity.tooShort) {
|
|
errorMsg = `Value must be at least ${field.minLength} characters`;
|
|
} else if (field.validity.tooLong) {
|
|
errorMsg = `Value must be at most ${field.maxLength} characters`;
|
|
} else if (field.validity.badInput) {
|
|
errorMsg = 'Invalid input type';
|
|
}
|
|
|
|
errors.push(`${fieldName}: ${errorMsg}`);
|
|
|
|
// Track first invalid field for focusing
|
|
if (index === 0) {
|
|
firstInvalidField = field;
|
|
}
|
|
|
|
// If field is in a collapsed section, expand it
|
|
const nestedContent = field.closest('.nested-content');
|
|
if (nestedContent && nestedContent.classList.contains('hidden')) {
|
|
// Find the toggle button for this section
|
|
const sectionId = nestedContent.id;
|
|
if (sectionId) {
|
|
// Try multiple selectors to find the toggle button
|
|
const toggleBtn = document.querySelector(`button[aria-controls="${sectionId}"], button[onclick*="${sectionId}"], [data-toggle-section="${sectionId}"]`) ||
|
|
nestedContent.previousElementSibling?.querySelector('button');
|
|
if (toggleBtn && toggleBtn.onclick) {
|
|
toggleBtn.click(); // Expand the section
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Focus and scroll to first invalid field after a brief delay
|
|
// (allows collapsed sections to expand first)
|
|
setTimeout(() => {
|
|
if (firstInvalidField) {
|
|
firstInvalidField.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
firstInvalidField.focus();
|
|
}
|
|
}, 200);
|
|
|
|
// Show error notification with details
|
|
if (errors.length > 0) {
|
|
// Format error message nicely
|
|
const errorList = errors.slice(0, 5).join('\n'); // Show first 5 errors
|
|
const moreErrors = errors.length > 5 ? `\n... and ${errors.length - 5} more error(s)` : '';
|
|
const errorMessage = `Validation failed:\n${errorList}${moreErrors}`;
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(errorMessage, 'error');
|
|
} else {
|
|
alert(errorMessage); // Fallback if showNotification not available
|
|
}
|
|
|
|
// Also log to console for debugging
|
|
console.error('Form validation errors:', errors);
|
|
}
|
|
|
|
// Report validation failure to browser (shows native validation tooltips)
|
|
form.reportValidity();
|
|
|
|
return false; // Prevent form submission
|
|
}
|
|
|
|
return true; // Validation passed
|
|
};
|
|
|
|
// Handle config save response with detailed error logging
|
|
window.handleConfigSave = function(event, pluginId) {
|
|
const btn = event.target.querySelector('[type=submit]');
|
|
if (btn) btn.disabled = false;
|
|
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
// Check if request was successful (2xx status codes)
|
|
if (status >= 200 && status < 300) {
|
|
// Try to get message from response JSON
|
|
let message = 'Configuration saved successfully!';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
message = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const responseData = JSON.parse(xhr.responseText);
|
|
message = responseData.message || message;
|
|
}
|
|
} catch (e) {
|
|
// Use default message if parsing fails
|
|
}
|
|
showNotification(message, 'success');
|
|
} else {
|
|
// Request failed - log detailed error information
|
|
console.error('Config save failed:', {
|
|
status: status,
|
|
statusText: xhr?.statusText,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
// Try to parse error response
|
|
let errorMessage = 'Failed to save configuration';
|
|
try {
|
|
if (xhr?.responseJSON) {
|
|
const errorData = xhr.responseJSON;
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
if (errorData.validation_errors) {
|
|
errorMessage += ': ' + errorData.validation_errors.join(', ');
|
|
}
|
|
} else if (xhr?.responseText) {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
if (errorData.validation_errors) {
|
|
errorMessage += ': ' + errorData.validation_errors.join(', ');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// If parsing fails, use status text
|
|
errorMessage = xhr?.statusText || errorMessage;
|
|
}
|
|
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Handle toggle response
|
|
window.handleToggleResponse = function(event, pluginId) {
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
if (status >= 200 && status < 300) {
|
|
// Update UI in place instead of refreshing to avoid duplication
|
|
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
|
|
const label = checkbox?.nextElementSibling;
|
|
|
|
if (checkbox && label) {
|
|
const isEnabled = checkbox.checked;
|
|
label.textContent = isEnabled ? 'Enabled' : 'Disabled';
|
|
label.className = `ml-2 text-sm ${isEnabled ? 'text-green-600' : 'text-gray-500'}`;
|
|
}
|
|
|
|
// Try to get message from response
|
|
let message = 'Plugin status updated';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
message = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const responseData = JSON.parse(xhr.responseText);
|
|
message = responseData.message || message;
|
|
}
|
|
} catch (e) {
|
|
// Use default message
|
|
}
|
|
showNotification(message, 'success');
|
|
} else {
|
|
// Revert checkbox state on error
|
|
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
|
|
if (checkbox) {
|
|
checkbox.checked = !checkbox.checked;
|
|
}
|
|
|
|
// Try to get error message from response
|
|
let errorMessage = 'Failed to update plugin status';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
errorMessage = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
}
|
|
} catch (e) {
|
|
// Use default message
|
|
}
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Handle plugin update response
|
|
window.handlePluginUpdate = function(event, pluginId) {
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
// Check if request was successful (2xx status)
|
|
if (status >= 200 && status < 300) {
|
|
// Try to parse the response to get the actual message from server
|
|
let message = 'Plugin updated successfully';
|
|
|
|
if (xhr && xhr.responseText) {
|
|
try {
|
|
const data = JSON.parse(xhr.responseText);
|
|
// Use the server's message, ensuring it says "update" not "save"
|
|
message = data.message || message;
|
|
// Ensure message is about updating, not saving
|
|
if (message.toLowerCase().includes('save') && !message.toLowerCase().includes('update')) {
|
|
message = message.replace(/save/i, 'update');
|
|
}
|
|
} catch (e) {
|
|
// If parsing fails, use default message
|
|
console.warn('Could not parse update response:', e);
|
|
}
|
|
}
|
|
|
|
showNotification(message, 'success');
|
|
} else {
|
|
console.error('Plugin update failed:', {
|
|
status: status,
|
|
statusText: xhr?.statusText,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
// Try to parse error response for better error message
|
|
let errorMessage = 'Failed to update plugin';
|
|
if (xhr?.responseText) {
|
|
try {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorMessage;
|
|
} catch (e) {
|
|
// If parsing fails, use default
|
|
}
|
|
}
|
|
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Refresh plugin config (with duplicate prevention)
|
|
window.refreshPluginConfig = function(pluginId) {
|
|
// Prevent concurrent refreshes
|
|
if (window.pluginConfigRefreshInProgress.has(pluginId)) {
|
|
return;
|
|
}
|
|
|
|
const container = document.getElementById(`plugin-config-${pluginId}`);
|
|
if (container && window.htmx) {
|
|
window.pluginConfigRefreshInProgress.add(pluginId);
|
|
|
|
// Clear container first, then reload
|
|
container.innerHTML = '';
|
|
window.htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
|
|
target: container,
|
|
swap: 'innerHTML'
|
|
});
|
|
|
|
// Clear flag after delay
|
|
setTimeout(() => {
|
|
window.pluginConfigRefreshInProgress.delete(pluginId);
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
// Plugin action handlers
|
|
window.runPluginOnDemand = function(pluginId) {
|
|
if (typeof window.openOnDemandModal === 'function') {
|
|
window.openOnDemandModal(pluginId);
|
|
} else {
|
|
showNotification('On-demand modal not available', 'error');
|
|
}
|
|
};
|
|
|
|
window.stopOnDemand = function() {
|
|
if (typeof window.requestOnDemandStop === 'function') {
|
|
window.requestOnDemandStop({});
|
|
} else {
|
|
showNotification('Stop function not available', 'error');
|
|
}
|
|
};
|
|
|
|
window.executePluginAction = function(actionId, actionIndex, pluginId) {
|
|
fetch(`/api/v3/plugins/action?plugin_id=${pluginId}&action_id=${actionId}`, {
|
|
method: 'POST'
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showNotification(data.message || 'Action executed', 'success');
|
|
} else {
|
|
showNotification(data.message || 'Action failed', 'error');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
showNotification('Failed to execute action', 'error');
|
|
});
|
|
};
|
|
}
|
|
|
|
function getAppComponent() {
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
return appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function updatePlugin(pluginId) {
|
|
try {
|
|
showNotification(`Updating ${pluginId}...`, 'info');
|
|
|
|
const response = await fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
// Refresh the plugin list
|
|
const appComponent = getAppComponent();
|
|
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
|
|
await appComponent.loadInstalledPlugins();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error updating plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function updateAllPlugins() {
|
|
try {
|
|
const plugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
|
|
|
|
if (!plugins.length) {
|
|
showNotification('No installed plugins to update.', 'warning');
|
|
return;
|
|
}
|
|
|
|
showNotification(`Checking ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} for updates...`, 'info');
|
|
|
|
let successCount = 0;
|
|
let failureCount = 0;
|
|
|
|
for (const plugin of plugins) {
|
|
const pluginId = plugin.id;
|
|
const pluginName = plugin.name || pluginId;
|
|
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
const status = data.status || 'info';
|
|
const message = data.message || `Checked ${pluginName}`;
|
|
|
|
showNotification(message, status);
|
|
|
|
if (status === 'success') {
|
|
successCount += 1;
|
|
} else {
|
|
failureCount += 1;
|
|
}
|
|
} catch (error) {
|
|
failureCount += 1;
|
|
showNotification(`Error updating ${pluginName}: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
const appComponent = getAppComponent();
|
|
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
|
|
await appComponent.loadInstalledPlugins();
|
|
}
|
|
|
|
if (failureCount === 0) {
|
|
showNotification(`Finished checking ${successCount} plugin${successCount === 1 ? '' : 's'} for updates.`, 'success');
|
|
} else {
|
|
showNotification(`Updated ${successCount} plugin${successCount === 1 ? '' : 's'} with ${failureCount} failure${failureCount === 1 ? '' : 's'}. Check logs for details.`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Bulk plugin update failed:', error);
|
|
showNotification('Failed to update all plugins: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
window.updateAllPlugins = updateAllPlugins;
|
|
|
|
|
|
async function uninstallPlugin(pluginId) {
|
|
try {
|
|
// Get plugin info from window.installedPlugins
|
|
const plugin = window.installedPlugins ? window.installedPlugins.find(p => p.id === pluginId) : null;
|
|
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
|
|
|
if (!confirm(`Are you sure you want to uninstall ${pluginName}?`)) {
|
|
return;
|
|
}
|
|
|
|
showNotification(`Uninstalling ${pluginName}...`, 'info');
|
|
|
|
const response = await fetch('/api/v3/plugins/uninstall', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
// Check if operation was queued
|
|
if (data.status === 'success' && data.data && data.data.operation_id) {
|
|
// Operation was queued, poll for completion
|
|
const operationId = data.data.operation_id;
|
|
showNotification(`Uninstall queued for ${pluginName}...`, 'info');
|
|
await pollUninstallOperation(operationId, pluginId, pluginName);
|
|
} else if (data.status === 'success') {
|
|
// Direct uninstall completed immediately
|
|
showNotification(data.message || `Plugin ${pluginName} uninstalled successfully`, 'success');
|
|
// Refresh the plugin list
|
|
await app.loadInstalledPlugins();
|
|
} else {
|
|
// Error response
|
|
showNotification(data.message || 'Failed to uninstall plugin', data.status || 'error');
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error uninstalling plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) {
|
|
if (attempt >= maxAttempts) {
|
|
showNotification(`Uninstall operation timed out for ${pluginName}`, 'error');
|
|
// Refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v3/plugins/operation/${operationId}`);
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success' && data.data) {
|
|
const operation = data.data;
|
|
const status = operation.status;
|
|
|
|
if (status === 'completed') {
|
|
// Operation completed successfully
|
|
showNotification(`Plugin ${pluginName} uninstalled successfully`, 'success');
|
|
await app.loadInstalledPlugins();
|
|
} else if (status === 'failed') {
|
|
// Operation failed
|
|
const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`;
|
|
showNotification(errorMsg, 'error');
|
|
// Refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
} else if (status === 'pending' || status === 'in_progress') {
|
|
// Still in progress, poll again
|
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
} else {
|
|
// Unknown status, poll again
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}
|
|
} else {
|
|
// Error getting operation status, try again
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error polling operation status:', error);
|
|
// On error, refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
}
|
|
}
|
|
|
|
// Assign to window for global access
|
|
window.uninstallPlugin = uninstallPlugin;
|
|
|
|
async function refreshPlugin(pluginId) {
|
|
try {
|
|
// Switch to the plugin manager tab briefly to refresh
|
|
const originalTab = app.activeTab;
|
|
app.activeTab = 'plugins';
|
|
|
|
// Wait a moment then switch back
|
|
setTimeout(() => {
|
|
app.activeTab = originalTab;
|
|
app.showNotification(`Refreshed ${pluginId}`, 'success');
|
|
}, 100);
|
|
|
|
} catch (error) {
|
|
app.showNotification('Error refreshing plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Format commit information for display
|
|
function formatCommitInfo(commit, branch) {
|
|
if (!commit && !branch) return 'Unknown';
|
|
const shortCommit = commit ? String(commit).substring(0, 7) : '';
|
|
const branchText = branch ? String(branch) : '';
|
|
|
|
if (branchText && shortCommit) {
|
|
return `${branchText} · ${shortCommit}`;
|
|
}
|
|
if (branchText) {
|
|
return branchText;
|
|
}
|
|
if (shortCommit) {
|
|
return shortCommit;
|
|
}
|
|
return 'Latest';
|
|
}
|
|
|
|
// Format date for display
|
|
function formatDateInfo(dateString) {
|
|
if (!dateString) return 'Unknown';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 1) {
|
|
return 'Today';
|
|
} else if (diffDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else if (diffDays < 30) {
|
|
const weeks = Math.floor(diffDays / 7);
|
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
|
} else {
|
|
// Return formatted date for older items
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
} catch (e) {
|
|
return dateString;
|
|
}
|
|
}
|
|
|
|
// Make functions available to Alpine.js
|
|
window.formatCommitInfo = formatCommitInfo;
|
|
window.formatDateInfo = formatDateInfo;
|
|
|