/** This file is part of Reactor.
 *  Copyright (c) 2020-2026 Kedron Holdings LLC, All Rights Reserved.
 *  Reactor is not public domain or open source. Distribution or derivative works are expressly prohibited.
 */

export const version = 26007;

import api from '/client/ClientAPI.js';
import Rulesets from "/client/Rulesets.js";
import Ruleset from "/client/Ruleset.js";
import Rule from "/client/Rule.js";
import Reaction from "/client/Reaction.js";
import Container from "/client/Container.js";
import Data from "/client/Data.js";

import Tab from "./tab.js";

import * as util from './reactor-ui-common.js';

import { _T, getLocale } from './i18n.js';

import { GridStack } from '/node_modules/gridstack/dist/gridstack.js';

const reactionStatus = [
    _T( [ '#reaction-status-ended', "ended" ] ),
    _T( [ '#reaction-status-ready', "ready" ] ),
    _T( [ '#reaction-status-running', "running" ] ),
    _T( [ '#reaction-status-delayed', "delayed" ] ),
    _T( [ '#reaction-status-waiting', "waiting" ] ),
    _T( [ '#reaction-status-suspended', "suspended" ] )
];

const DEFAULT_LAYOUT = [
    { id: "widget0", w: 12, h: 2, content: '<div class="re-status-widget" data-widget="DefaultWidget"></div>' },
    { id: "widget1", w:  6, h: 2, content: '<div class="re-status-widget" data-widget="SetRules"></div>' },
    { id: "widget2", w:  6, h: 2, content: '<div class="re-status-widget" data-widget="RunningReactions"></div>' },
    { id: "widget3", w: 12, h: 2, content: '<div class="re-status-widget" data-widget="CurrentAlerts"></div>' }
];

const WIDGETS = [
    { id: "DefaultWidget", name: _T("New Widget") },
    { id: "SetRules", name: _T("Set Rules") },
    { id: "RunningReactions", name: _T("Running Reactions") },
    { id: "CurrentAlerts", name: _T("Current Alerts") },
    { id: "RecentEntities", name: _T("Recently Changed Entities") },
    { id: "RuleHistory", name: _T("Rule History") },
    { id: "ReactionHistory", name: _T("Reaction History") },
    { id: "ControllerStatus", name: _T("Controller Status") }
];

const WIDGET = `<div class="" gs-x="0" gs-y="0" gs-w="12" gs-h="2"><div class="grid-stack-item-content"></div></div>`;

const WIDGET_FRAME = `<div class="re-status-widget card" data-widget="DefaultWidget"></div>`;

const WIDGET_HEAD = `<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">\
  <h6 class="m-0 fw-bold text-secondary"></h6>\
  <div class="widget-controls text-end"></div>\
</div>`;

const WIDGET_BODY = `<div class="card-body pt-0 overflow-auto"></div>`;

export default (function($) {

    var tabInstance = false;

    const alertClasses = [ "bi-x-octagon-fill text-danger", "bi-exclamation-triangle-fill text-warning", "bi-info-circle-fill text-info" ];

    function relativeTime( t ) {
        let delta = Date.now() - t;
        if ( delta < 86400000 ) {
            return new Date(t).toLocaleTimeString();
        }
        delta = Math.floor( delta / 3600000 ); // now hours
        if ( delta <= 48 ) {
            return _T(['#statuswidgets-hrsago', '{0:d} hr{0:"s"?!=1} ago'], delta);
        }
        return new Date(t).toLocaleDateString();
    };

    // Keeps only the enclosing (first) <div> of the widget contents (for localStorage of layout)
    function clipSavedContent( v ) {
        return v.replace( /^([<]div\s+[^>]+[>]).*/i, "$1</div>" );
    }

    class StatusTab extends Tab {
        constructor( $parent ) {
            super( 'tab-status', $parent );
            this.lastCompact = 0;
            this.grid = false;
        }

        async activate( event ) {  // eslint-disable-line no-unused-vars
            // console.log( this.$tab, "activate!", event );
            const $tab = this.$tab;
            const self = this;

            this.lastCompact = 0;
            this.grid = false;

            // $tab.addClass('w-100');

            const $stack = $( '<div class="grid-stack"></div>' );

            // For v11+ of GridStack, "content" option no longer available (security change), but can be emulated.
            // Ref: https://www.npmjs.com/package/gridstack#migrating-to-v11
            // NOTE: REAL apps would sanitize-html or DOMPurify before blinding setting innerHTML. see #2736
            GridStack.renderCB = function(el /*: HTMLElement */, w /*: GridStackNode */) {
                /** OK. This is fucking horrible. This renderCB() bullshit doesn't work well with anything but
                 *  the default drag case (where the entire div.grid-stack-item is the drag handle). Here's the
                 *  scenario. An initial load can set el.innerHTML = w.content as the docs recommend, and
                 *  dragging works with a drag handle on the INTERIOR of div.grid-stack-item. But, the way we
                 *  are set up, when you finish dragging, we compact the layout and save it, then reload it To
                 *  ensure that the saved data and the displayed layout are the same (WYSIWYG). The reload
                 *  breaks, because it calls this renderCB bullshit on an existing element; without some extra
                 *  consideration here, that just resets the innerHTML to the passed (existing) content (but
                 *  in HTML form, not as the existing DOM subtree), so if we just reset innerHTML to that
                 *  it creates a new DOM subtree, the drag handler on the old tree disappears, and you get no more
                 *  dragging. Fuck me. If we don't use this renderCB nightmare, then on load() the restored
                 *  widget content HTML is displayed as text rather than rendered as HTML. That would be fixable
                 *  by just having each widget re-render itself after any load. But to do that, we'd have to
                 *  somehow save the type for each widget. Right now, that's done in classes on the interior
                 *  of div.grid-stack-item-content. We'd need to move that up to that div or its parent,
                 *  but then it doesn't come to us from a save(). What a shit show.
                 *  Anyway, based on our use, the simple workaround is to only set the content if the widget is
                 *  empty (i.e. doesn't have our content).
                 */
                //console.log("renderCB",el,w.content);
                const $el = $( el );
                if ( 0 === $( 'div.re-status-widget', $el ).length && w?.content ) {
                    // See above. We only set content if the div.grid-stack-item-content is empty (initial load).
                    // el.innerHTML = w.content;
                    $( el ).html( w.content );
                }
            };

            // Ref: https://www.jqueryscript.net/layout/Responsive-Fluid-Drag-and-Drop-Grid-Layout-with-jQuery-gridstack-js.html#jquery
            $stack.appendTo( $tab );
            this.grid = GridStack.init({
                    // auto: true,
                    // acceptWidgets: false,
                    animate: true,
                    float: true,
                    // column: 12,
                    // row: 6,
                    minRow: 1,
                    // height: 0,
                    margin: 8,
                    cellHeight: '133px', /* was 20vw, fixed value for now ??? */
                    //cellHeightUnit: 'px',
                    alwaysShowResizeHandle: 'mobile',
                    draggable: {
                        handle: '.card-header',
                        scroll: true
                    },
                    disableResize: false,
                    disableOneColumnMode: false,
                    removable: 'nav.navbar',
                    removeTimeout: 2000,
                    columnOpts: {
                        layout: 'compact',
                        breakpointForWindow: true,  // test window vs grid size
                        breakpoints: [ {w:576, c:1}, {w:768, c:1}, {w:992, c:4}, {w:1200, c:8}, {w:1400, c:12} ]
                    }
            });

            let layout = this.loadGridLayout();
            this.grid.load( layout );

            /* "Add widget" widget -- race condition! We need to let grid load happen first. */
            setTimeout( () => self.placeWidgetAdder( $stack ), 100 );

            $( 'div.re-status-widget', $tab ).each( async ( ix, w ) => {
                const $w = $( w );
                const widgetName = $w.data( 'widget' );
                try {
                    await self[ `draw${widgetName}` ]( self.initWidgetFrame( $w ) );
                } catch ( err ) {
                    $w.html( '<p>An error occurred while drawing the widget</p> ' );
                    $w.append( widgetName ).append( '; ' ).append( String( err ) );
                    console.error( err );
                }
            });

            const bound_save = this.saveGridLayout.bind( this );
            // this.grid.on( 'resizestop', bound_save );
            this.grid.on( 'change', bound_save );
            this.grid.on( 'removed', bound_save );
            this.grid.on( 'added', bound_save );

            const cookies = document.cookie.split( /;\s*/ ) || [];
            const cookie = cookies.find( el => el.match( /^reactor-session=/ ) );
            const token = cookie ? ( cookie.split( /=/ )[1] || "" ).split( /\|/ )[1] : null;
            $.ajax({
                dataType: 'json',
                url: '/api/v1/systime',
                timeout: 2000,
                beforeSend: function( xhr ) { if (token) xhr.setRequestHeader("authorization", "Bearer "+token); }
            }).done( data => {
                let now = new Date();
                let hst = new Date( data.time );
                let diff = Math.abs( hst - now );
                console.log("UI TIME CHECK: Browser", now.toISOString(), "offset", -now.getTimezoneOffset(), "; host",
                    hst.toISOString(), "offset(mins)", data.tzoffset, "; diff(ms)", diff);
                if ( diff >= 5000 ) {
                    util.showSysModal({
                        title: _T('System Configuration Check'),
                        body: _T(['#sysconf-error-time',"The time on this system and on the Reactor host are significantly different. This may be due to incorrect system configuration on either or both. Please check the configuration of both systems. The host reports {0}; browser reports {1}; difference {2:.3f} seconds"],
                            hst.toISOString(), now.toISOString(), Math.floor( diff + 0.5 ) / 1000),
                        buttons: [{
                            class: "btn-primary",
                            label: "OK",
                            close: true
                        }]
                    });
                }
            }).fail( ( e ) => {
                console.log( "Reactor host time check failed ", e );
            });
        }

        placeWidgetAdder( $stack ) {
            const self = this;
            let $item = $( 'div.grid-stack-item[gs-id="widget-adder"]', $stack );
            if ( 0 === $item.length ) {
                let bottom = 0;
                $( 'div.grid-stack-item', $stack ).each( (ix, obj) => {
                    const $obj = $( obj );
                    bottom = Math.max( bottom, ( parseInt( $obj.attr( "gs-y" ) ) || 0 )
                        + ( parseInt( $obj.attr( "gs-h" ) ) || 1 ) );
                });
                console.log("Placing widget-adder at", bottom);
                $item = $( WIDGET );
                $item.attr({
                    'id': 'widget-adder',
                    'gs-id': 'widget-adder',
                    'gs-y': bottom,
                    'gs-x': 0,
                    'gs-w': Math.max( 2, self.grid.getColumn() || 2 ),
                    'gs-h': 1,
                    'gs-noresize': 'true',
                    'gs-nomove': 'true'
                });
                $( 'div.grid-stack' ).append( $item );
                self.grid.makeWidget( `#widget-adder` );
            }

            const $content = $( 'div.grid-stack-item-content', $item )
                .removeClass( "shadow border border-2 rounded-3 d-flex flex-row justify-content-center" ) // remove legacy classes -- REMOVE AFTER 2025-12-01
                .empty();
            $( '<div class="re-add-widget border border-2 rounded-3 text-center"><i class="bi bi-plus-circle"></i></div>' )
                .appendTo( $content );

            $( 'div.re-add-widget i.bi-plus-circle', $content )
                .attr( 'title', _T( 'Click to add a new widget' ) )
                .off( 'click.reactor' )
                .on( 'click.reactor', async ( ev ) => {
                    const $me = $( ev.target ).closest( '.grid-stack-item' );
                    const wid = util.getUID( "widget" );
                    const $widget = $( WIDGET );
                    $widget.attr({
                        'id': wid,          // example sets this, but that leaves us with gs-id===undefined in v12.1.1
                        'gs-id': wid,       // so we set both.
                        'gs-y': parseInt( $me.attr( 'gs-y' ) ) || 0,    // push widget-adder down
                        'gs-x': 0,
                        'gs-w': Math.max( 2, self.grid.getColumn() || 2 ),
                        'gs-h': 2
                    }).addClass( 'ui-resizable-autohide' );
                    $( 'div.grid-stack-item-content', $widget).append( self.initWidgetFrame( $( WIDGET_FRAME ) ) );
                    $( 'div.grid-stack' ).append( $widget );
                    console.log("Creating new widget", $widget.attr("gs-id"),
                        "x,y", $widget.attr("gs-x"), $widget.attr("gs-y"),
                        "w,h", $widget.attr("gs-w"), $widget.attr("gs-h"));
                    self.grid.makeWidget( `#${wid}` );

                    // Draw widget using actual DOM element now in place.
                    await self.drawDefaultWidget( $( 'div.re-status-widget', $widget ) );

                    self.saveGridLayout();
                });
        }

        initWidgetFrame( $card ) {
            /* If frame version doesn't match, clear frame and reconstruct */
            const upgrading = $card.attr( 'data-version' ) !== String( version );
            if ( upgrading ) {
                console.log("Clearing/reconstructing widget frame for", $card);
                $card.removeClass();
            }
            $card.addClass( 're-status-widget card border border-2 rounded-3' ).removeClass( (ix, name) => {
                // console.log("card",$card,"index",ix,"classes",name);
                return name.split(/\s+/).filter( el => el.match( /^border-[a-z]+/i ) ).join(' ');
            });

            // Header. We're particular about the way we handle, because gridstack may have already bound handlers we need.
            let $head = $( '.card-header', $card );
            $head.each( (ix,el)=> {
                if ( ix > 0 ) $( el ).remove();
            });
            $head = $head.first();
            if ( 0 === $head.length ) {
                $( WIDGET_HEAD ).appendTo( $card );
            } else if ( upgrading ) {
                // Surgical upgrade. Replace classes and content individually (to preserve bound handlers on existing element)
                const $hc = $( WIDGET_HEAD );
                $head.removeClass( (ix,name) => name.split( /\s+/ ).filter( el => el !== "card-header" ).join(' ') )
                    .addClass( $hc.attr( 'class' ) );
                $head.html( $hc.html() );
            }

            // Body. We can be less precise here, because gridstack doesn't have any business in the widget body.
            let $body = $( '.card-body', $card );
            if ( upgrading || 0 === $body.length ) {
                $body.remove();
                $( WIDGET_BODY ).appendTo( $card );
            } else {
                // Only one body allowed.
                $body.each( (ix,el) => {
                    if ( ix > 0 ) $( el ).remove();
                });
            }

            $card.attr( 'data-version', version );

            return $card;
        }

        async drawDefaultWidget( $card ) {
            const self = this;
            $card.addClass( 'border-danger' );

            $( '.card-header h6', $card ).text( _T('About Status Panel Widgets') );

            let $body = $( '.card-body', $card );
            $body.text( _T( '#status-widget-info-text' ) );
            $( `<div class="mt-2"> \
  <fieldset> \
    <label class="form-label">${_T( 'Choose Widget:' )}</label> \
    <select class="form-select re-status-widget-select"></select> \
  </fieldset> \
</div>` )
                .appendTo( $body );
            let $m = $( 'select.re-status-widget-select', $body );
            for ( let w of WIDGETS ) {
                $( '<option></option>' ).val( w.id ).text( w.name ).appendTo( $m );
            }
            $m.val( WIDGETS[0].id );
            $m.on( 'change', async ( ev ) => {
                /* Widget type chosen. Set the type. */
                const sel = $( ev.target ).val();
                $card.data( 'widget', sel ).attr( 'data-widget', sel );

                /* Clear the default widget content and decorations and draw the new widget type */
                await self[`draw${sel}`]( self.initWidgetFrame( $card ) );

                self.saveGridLayout( ev );
            });
        }

        async drawSetRules( $card ) {
            const bound_handler = this.updateSetRules.bind( this, $card );

            $card.addClass( 'border-primary' );
            $( '.card-header h6', $card ).text( _T('Set Rules') );

            let $body = $( 'div.card-body', $card ).empty();
            $( '<div class="re-status-list"></div>' )
                .text( _T( "Loading..." ) )
                .appendTo( $body );
            $( 'div.card-header h6', $card ).empty()
                .append( `<span>${ _T( 'Set Rules' ) }</span>` );

            // Subscribe to all Rulesets. Each ruleset will notify us when one of its rules changes.
            // This is done async, because it can take a while on the first go...
            const self = this;
            Rulesets.getRulesets().then( sets => {
                for ( const set of sets ) {
                    self.subscribe( set, bound_handler );
                    // Fetch all rules for this set: ensures set is subscribed to its rules
                    // (so rule notifies set, and set notifies us)
                    set.getRules().catch( (err) => {
                        console.error( err );
                    });
                }
                return sets;
            }).catch( err => {
                console.error( err );
            });

            await this.updateSetRules( $card );
        }

        async updateSetRules( $card, ev ) {
            //console.log( "updateSetRules", ev );
            if ( ev && ev.type !== "rule-state-changed" ) {
                return;
            }

            const $list = $card.find( 'div.re-status-list' );

            const l = [];
            try {
                const stat = await api.getRuleStatus();
                for ( const rinfo of Object.values( stat ) ) {
                    if ( true === util.coalesce( rinfo.state ) || ( "boolean" === typeof rinfo.override ) ) {
                        l.push( rinfo );
                    }
                }
                //console.log("updateSetRules",l.length,"set rules");
            } catch ( err ) {
                console.error( err );
            }

            $list.empty();
            l.sort( function( a, b ) {
                if ( a.state_since === b.state_since ) {
                    return a.name.localeCompare( b.name, undefined, { sensitivity: 'base' } );
                }
                return b.state_since - a.state_since;    /* N.B. reverse sort! */
            }).forEach( entry => {
                const $row = $( '<div class="row gx-0 py-1 align-items-start"></div>' ).appendTo( $list );
                let $el = $( '<div class="col-5"></div>' )
                    .attr( 'title', entry.enabled ? "" : "This rule is disabled" )
                    .appendTo( $row );
                $( '<a></a>' ).attr( 'href', `#rules/rule/${entry.id}` )
                    .text( entry.name )
                    .appendTo( $el );
                if ( ! entry.enabled ) {
                    $( '<span class="ms-2 text-danger"><i class="bi bi-exclamation-triangle"></i></span>' )
                        .attr( 'title', _T( 'This rule is disabled' ) )
                        .appendTo( $el );
                } else if ( "boolean" === typeof entry.override ) {
                    $( '<span class="ms-2 text-warning"><i class="bi bi-exclamation-triangle"></i></span>' )
                        .attr( 'title', _T( entry.override ? '#rule-override-set' : '#rule-override-reset' ) )
                        .appendTo( $el );
                }
                const $set = $( '<div class="col-3"></div>' ).appendTo( $row );
                Ruleset.getInstance( entry.ruleset ).then( set => {
                    $( '<a></a>' ).attr( 'href', `#rules/ruleset/${set.id}` )
                        .text( set.name )
                        .appendTo( $set );
                }).catch( err => {
                    $set.text( "?" );
                    console.error( "Loading set", entry.set, err );
                });
                $( '<div class="col-4 text-end"></div>' ).text( relativeTime( entry.state_since ) )
                    .attr( 'title', new Date( entry.state_since ).toISOString() )
                    .appendTo( $row );
            });

            const $title = $( 'div.card-header h6', $card );
            let $badge = $( 'span.badge', $title );
            if ( l.length > 0 ) {
                if ( 0 === $badge.length ) {
                    $badge = $( '<span class="badge bg-primary ms-1">' ).appendTo( $title );
                }
                $badge.text( l.length );
            } else {
                $badge.remove();
                $list.text( _T( "There are no set rules at this moment." ) );
            }
        }

        async drawRunningReactions( $card ) {
            $card.addClass( 'border-info' );

            $( '.card-header h6', $card ).text( _T('Running Reactions') );

            const $body = $( 'div.card-body', $card ).empty();
            const $list = $( '<div class="re-status-list"></div>' ).appendTo( $body );
            $( '<div class="row gx-0"><div class="col-auto text-align-center">Loading...</div></div>' )
                .appendTo( $list );

            let dobj = await Data.getInstance( Container.getInstance( 'states' ), 'reaction_queue' );
            if ( dobj ) {
                this.subscribe( dobj, this.updateRunningReactions.bind( this, $card ) );
            }

            await this.updateRunningReactions( $card );
        }

        insertCountDownTimer( $col, dt ) {
            function tick() {
                let delta = dt - Date.now();
                if ( delta >= 0 ) {
                    delta = Math.floor( ( delta + 999 ) / 1000 );
                    let ss = delta % 60;
                    ss = ss < 10 ? '0' + ss : ss;
                    let mm = Math.floor( delta / 60 );
                    mm = mm < 10 ? '0' + mm : mm;
                    $col.text( `${mm}:${ss}` );
                    setTimeout( () => { tick( $col, dt ); }, 500 );
                } else {
                    $col.empty();
                }
            }
            tick( $col, dt );
        }

        async updateRunningReactions( $card ) {
            const $list = $( 'div.card-body div.re-status-list', $card ).empty();
            let dobj = await Data.getInstance( Container.getInstance( 'states' ), 'reaction_queue' );
            let $title = $( 'div.card-header h6', $list.closest( 'div.card' ) );
            $( 'span.badge', $title ).remove();

            if ( Array.isArray( dobj.value ) && dobj.value.length > 0 ) {
                $title.append( $( '<span class="badge bg-success ms-1">' ).text( dobj.value.length ) );
                dobj.value.forEach( entry => {
                    let $row = $( '<div class="row gx-0 py-1 align-items-start"></div>' )
                        .attr( 'id', 'running-' + entry.tid )
                        .attr( 'data-reaction', entry.id )
                        .data( 'reaction', entry.id )
                        .appendTo( $list );
                    // $( '<div class="col-auto"></div>' ).text( entry.tid ).appendTo( $row );
                    let $btn = $( '<div class="col-auto ps-1 pe-0"><button class="btn bi-btn bi-btn-sm btn-outline-danger"><i class="bi bi-x"></i></button></div>' )
                        .on( 'click', ev => {
                            const id = $( ev.currentTarget ).closest( 'div.row' ).data( 'reaction' );
                            console.log( 'stop reaction',id );
                            api.stopReaction( id, null, "user request" );
                        })
                        .appendTo( $row );
                    $( 'button', $btn ).attr( 'title', _T( 'Stop this reaction/task' ) );
                    $( '<div class="col ps-2 pe-0 re-task-name"></div>' ).appendTo( $row );
                    if ( entry.title ) {
                        $( '.re-task-name', $row ).text( entry.title + ' (' + entry.next_step + ')' );
                    } else if ( entry.rule ) {
                        /* Rule-based reaction. May be SET/RESET or subgroup */
                        Rule.getInstance( entry.rule ).then( rule => {
                            let reset = !!entry.id.match( /:R/ );
                            let nstep = ( ( ( reset ? rule.react_reset : rule.react_set ) || {}).actions || []).length;
                            $( '.re-task-name', $row ).text( rule.name + "<" + ( reset ? _T("RESET") : _T("SET") ) + '> (' +
                                entry.next_step + '/' + nstep + ')' );
                        });
                    } else {
                        Reaction.getInstance( entry.id ).then( reaction => {
                            let nstep = ( reaction.actions || [] ).length;
                            $( '.re-task-name', $row ).text( reaction.name + ' (' + entry.next_step + '/' + nstep + ')'  );
                        }).catch( err => {
                            console.error( err );
                            $col.text( String( err ) );
                        });
                    }
                    $( '<div class="col-auto ps-2 pe-0 text-center"></div>' ).text( reactionStatus[ entry.status + 1 ] || entry.status )
                        .appendTo( $row );
                    let $col = $( '<div class="col-auto ps-2 pe-1 text-end"></div>' ).appendTo( $row );
                    if ( 2 === entry.status && entry.time ) {
                        let delta = entry.time - Date.now();
                        if ( delta >= 3600000 ) {
                            $col.text( relativeTime( entry.time ) );
                        } else {
                            this.insertCountDownTimer( $col, entry.time );
                        }
                    }
                });
            } else {
                $list.text( _T("There are no running reactions.") );
            }
        }

        async drawCurrentAlerts( $card ) {
            $card.addClass( 'border-success' );

            const $head = $( '.card-header', $card );
            $( 'h6', $head ).text( _T('Current Alerts') )

            const $c = $( '.card-header .widget-controls', $card );
            if ( 0 === $( 'div#alertMenu', $c ).length ) {
                $( `<div id="alertMenu" class="dropdown no-arrow">\
  <a class="dropdown-toggle" href="#" role="button" id="alertMenuLink" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">\
    <i class="bi bi-three-dots-vertical text-gray-500"></i>\
  </a>\
  <div class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="alertMenuLink" style="">\
    <!-- <div class="dropdown-header">Dropdown Header:</div> -->\
    <a id="re-alerts-clear" class="dropdown-item" href="#">${_T('Clear All')}</a>\
<!-- \
    <a class="dropdown-item" href="#">Another action</a>\
    <div class="dropdown-divider"></div>\
      <a class="dropdown-item" href="#">Something else here</a>\
    </div>\
-->\
  </div>\
</div>` )
                    .appendTo( $c );
            }
            $( 'a#re-alerts-clear', $head ).on( 'click.reactor', () => api.clearAlerts() );

            const $body = $( 'div.card-body', $card ).empty();
            $( '<div class="re-status-list d-flex flex-column">Loading alerts...</div>' ).appendTo( $body );

            const dobj = await Data.getInstance( Container.getInstance( 'states' ), 'sys_alerts' );
            await dobj.refresh();
            this.subscribe( dobj, this.updateAlerts.bind( this, $card ) );

            api.on( 'structure_update.' + this.id, async () => {
                console.log(this,"received structure_update");
                const dobj = await Data.getInstance( Container.getInstance( 'states' ), 'sys_alerts' );
                await dobj.refresh();
                await this.updateAlerts( $card );
            });

            await this.updateAlerts( $card );
        }

        async updateAlerts( $card ) {
            const $list = $( 'div.re-status-list', $card ).empty();

            const dobj = await Data.getInstance( Container.getInstance( 'states' ), 'sys_alerts' );

            let low = null;
            $( 'a#re-alerts-clear', $card ).toggleClass( 'disabled', 0 === dobj.value.alerts.length );
            if ( 0 === dobj.value.alerts.length ) {
                $card.removeClass( "border-danger border-warning border-info" )
                    .addClass( "border-success" );
                $list.text( _T("There are no current alerts.") );
            } else {
                dobj.value.alerts.forEach( alert => {
                    if ( low === null || alert.severity < low ) {
                        low = alert.severity;
                    }
                    let $row = $( '<div class="re-alert-row d-flex flex-row pt-2 align-items-start flex-nowrap"></div>' )
                        .attr( 'id', 'alert-' + alert.id )
                        .appendTo( $list );
                    $( '<div class=""></div>' ).appendTo( $row )
                        .append( $( '<i class="bi bi-size-3"></i>' ).addClass( alertClasses[ alert.severity ] || "bi-x" ) );
                    let $col = $( '<div class="pt-1 ps-2"></div>' ).appendTo( $row );
                    $( '<div class="text-break"></div>' ).appendTo( $col ).text( alert.message );
                    if ( ( alert.options || {} ).detail ) {
                        const $l = $( '<div><tt class="text-break"></tt></div>' ).appendTo( $col );
                        $( 'tt', $l ).text( alert.options.detail.trim() );
                    }
                    if ( "#" !== ( ( alert.options || {} ).link || "#" ) ) {
                        const $l = $( '<div><a target="_blank"></a></div>' )
                            .appendTo( $col );
                        $( 'a', $l )
                            .attr( 'href', alert.options.link )
                            .text( alert.options.linktitle || alert.options.link );
                    }
                    $( '<div><small></small></div>' ).appendTo( $col );
                    $( 'small', $col ).text( _T( ['#alert-time-last', 'Last {0}'], relativeTime( alert.ts ), alert.ts ) );
                    if ( alert.count && alert.count > 1 ) {
                        $( '<span></span>' ).text( _T("; this alert has repeated {0:d} times.", alert.count ) )
                            .appendTo( $( 'small', $col ) );
                    }
                    $( '<div class="ms-auto pt-1 ps-2 text-end"></div>' ).appendTo( $row )
                        //.append( '<button class="btn bi-btn bi-btn-sm btn-success re-alert-go" title="Go To"><i class="bi bi-bullseye"></i></button>' )
                        .append( `<button class="btn bi-btn bi-btn-sm btn-outline-primary re-alert-ack" title="${_T('Dismiss Alert')}"><i class="bi bi-x"></i></button>` );
                });

                $card.removeClass( 'border-success' )
                    .toggleClass( 'border-danger', low === 0 )
                    .toggleClass( 'border-warning', low === 1 )
                    .toggleClass( 'border-info', low === 2 );
            }

            let $title = $( 'div.card-header h6', $list.closest( 'div.card' ) ).empty()
                .append( `<span>${_T('Current Alerts')}</span>` );
            if ( dobj.value.alerts.length > 0 ) {
                $title.append( $( '<span class="badge ms-1">' ).text( dobj.value.alerts.length )
                    .toggleClass( 'bg-danger', low === 0 )
                    .toggleClass( 'bg-warning', low === 1 )
                    .toggleClass( 'bg-info', low === 2 )
                );
            }

            $( 'button.re-alert-ack', $list ).on( 'click.reactor', (event) => {
                let $el = $( event.currentTarget ).closest( 'div.re-alert-row' );
                let id = parseInt( $el.attr( 'id' ).replace( "alert-", "" ) );
                if ( ! isNaN( id ) ) {
                    api.dismissAlert( id );
                } else {
                    dobj.refresh();
                }
                return false;
            });
        }

        async drawRecentEntities( $card ) {
            const maxRows = 32;

            $card.addClass( 'border-secondary' );

            $( '.card-header h6', $card ).text( _T('Recently Changed Entities') );

            const $body = $( 'div.card-body', $card ).empty();
            const $list = $( '<div id="re-status-entities" class="re-status-list"></div>' )
                .attr( "data-maxrows", maxRows )
                .data( "maxrows", maxRows )
                .appendTo( $body );

            // Pre-populate the list.
            let l = api.getEntities();
            l.sort( (a, b) => {
                let t1 = a.getLastUpdate();
                let t2 = b.getLastUpdate();
                if ( t1 === t2 ) {
                    return a.getName().localeCompare( b.getName(), undefined, { sensitivity: 'base' } );
                }
                return t1 < t2 ? 1 : -1;    /* Reverse sort */
            });
            const numshow = parseInt( $list.data( 'maxrows' ) ) || 32;
            l.splice( numshow );
            l.forEach( e => {
                let $row = $( '<div class="row gx-0 py-1 align-items-start"></div>' ).appendTo( $list );
                let $el = $( '<div class="col-12 col-md-6 col-lg-4 overflow-hidden"></div>' ).appendTo( $row );
                $( '<a></a>' ).attr( 'href', `#entities/${e.getCanonicalID()}` ).text( e.getName() ).appendTo( $el );
                $( '<div class="col-6 col-lg-4 d-none d-lg-block overflow-hidden"></div>' ).text( e.getCanonicalID() ).appendTo( $row );
                $( '<div class="col-6 col-md-3 col-lg-2 overflow-hidden"></div>' ).text( String( e.getPrimaryValue() ) ).appendTo( $row );
                $( '<div class="col-6 col-md-3 col-lg-2 overflow-hidden text-end"></div>' ).text( relativeTime( e.getLastUpdate() ) ).appendTo( $row );
            });

            api.on( 'entity_change.' + this.id, this.updateRecentEntities.bind( this, $card ) );

            // ??? TO-DO: handle resize (changes maxrows)
        }

        async updateRecentEntities( $card, e ) {
            //console.log( 'status updateRecentEntities with entity', e );
            const $list = $( 'div#re-status-entities', $card );
            const $rows = $( 'div.row', $list );
            const numshow = parseInt( $list.data( 'maxrows' ) ) || 32;
            if ( $rows.length >= numshow ) {
                $rows[ numshow - 1 ].remove();
            }
            const $row = $( '<div class="row gx-0 py-1 align-items-start"></div>' ).prependTo( $list );
            let $el = $( '<div class="col-12 col-md-6 col-lg-4 overflow-hidden"></div>' ).appendTo( $row );
            $( '<a></a>' ).attr( 'href', `#entities/${e.getCanonicalID()}` ).text( e.getName() ).appendTo( $el );
            $( '<div class="col-6 col-lg-4 d-none d-lg-block overflow-hidden"></div>' ).text( e.getCanonicalID() ).appendTo( $row );
            $( '<div class="col-6 col-md-3 col-lg-2 overflow-hidden"></div>' ).text( String( e.getPrimaryValue() ) ).appendTo( $row );
            $( '<div class="col-6 col-md-3 col-lg-2 overflow-hidden text-end"></div>' ).text( relativeTime( e.getLastUpdate() ) ).appendTo( $row );
        }

        async drawRuleHistory( $card ) {
            $card.addClass( 'border-primary' );

            $( '.card-header h6', $card ).text( _T('Rule History') );

            const $body = $( 'div.card-body', $card ).empty();
            $( '<div class="re-status-list">Loading...</div>' ).appendTo( $body );

            const dobj = await Data.getInstance( Container.getInstance( 'states' ), 'rule_history' );
            this.subscribe( dobj, this.updateRuleHistory.bind( this, $card ) );

            await this.updateRuleHistory( $card );
        }

        async updateRuleHistory( $card ) {
            /* ??? Dynamic update, maybe 25 at a time until Y position > window length?
               Need to reaction to resize as well then.
            */
            const dobj = await Data.getInstance( Container.getInstance( 'states' ), 'rule_history' );
            const $list = $( 'div.re-status-list', $card ).empty();
            const n = dobj.value.history.length;
            for ( let ix = n-1; ix >= Math.max(0, n-100); ix-- ) {
                const e = dobj.value.history[ ix ];
                try {
                    const rule = await Rule.getInstance( e.rule_id );
                    const ruleset = await rule.getRuleset();
                    let $row = $( '<div class="row gx-0 py-1 align-items-start"></div>' ).appendTo( $list );
                    let $el = $( '<div class="col-12 col-md-7 overflow-hidden"></div>' )
                        .attr( 'title', e.rule_id )
                        .appendTo( $row );
                    $( '<a></a>' ).attr( 'href', `#rules/rule/${rule.id}` )
                        .text( rule.name || rule.id )
                        .appendTo( $el );
                    $( '<a></a>' ).attr( 'href', `#rules/ruleset/${ruleset.id}` )
                        .addClass('rule-ruleset-link')
                        .text( ruleset.name || ruleset.id )
                        .appendTo( $el );
                    $( '<div class="col-6 col-md-2 overflow-hidden text-center"></div>' )
                        .text( null === e.new_state ? _T("null") : ( e.new_state ? _T("SET") : _T("RESET") ) )
                        .appendTo( $row );
                    $( '<div class="col-6 col-md-3 overflow-hidden text-end"></div>' )
                        .text( relativeTime( e.new_stamp ) )
                        .appendTo( $row );
                } catch ( err ) {
                    console.error( `Rule History Widget: can't display ${e.rule_id}: ${err.message}` );
                }
            }
        }

        async drawReactionHistory( $card ) {
            $card.addClass( 'border-info' );

            $( '.card-header h6', $card ).text( _T('Reaction History') );

            const $body = $( 'div.card-body', $card ).empty();
            $( '<div class="re-status-list">Loading...</div>' ).appendTo( $body );

            const dobj = await Data.getInstance( Container.getInstance( 'states' ), 'reaction_history' );
            this.subscribe( dobj, this.updateReactionHistory.bind( this, $card ) );

            await this.updateReactionHistory( $card );
        }

        async updateReactionHistory( $card ) {
            /* ??? Dynamic update, maybe 25 at a time until Y position > window length?
               Need to reaction to resize as well then.
            */
            const dobj = await Data.getInstance( Container.getInstance( 'states' ), 'reaction_history' );
            const $list = $( 'div.re-status-list', $card ).empty();
            const n = dobj.value.history.length;
            for ( let ix = n-1; ix >= Math.max(0, n-100); ix-- ) {
                const e = dobj.value.history[ ix ];
                let $row = $( '<div class="row gx-0 py-1 align-items-start"></div>' ).appendTo( $list );
                let $el = $( '<div class="col-12 col-md-6 overflow-hidden"></div>' )
                    .attr( 'title', e.reaction_id )
                    .appendTo( $row );
                try {
                    if ( e.reaction_id.match( /:[SR]$/ ) ) {
                        /* Rule Set or Reset Reaction */
                        const rid = e.reaction_id.slice( 0, -2 );
                        const rule = await Rule.getInstance( rid );
                        const ruleset = await rule.getRuleset();
                        $( '<a></a>' ).attr( 'href' , `#rules/rule/${rule.id}` )
                            .text( rule.name )
                            .appendTo( $el );
                        $( '<span></span>' ).addClass( 'ms-1' )
                            .text( _T(e.reaction_id.endsWith(":S") ? "SET" : "RESET") )
                            .appendTo( $el );
                        $( '<a></a>' ).attr( 'href', `#rules/ruleset/${ruleset.id}` )
                            .addClass( 'rule-ruleset-link' )
                            .text( ruleset.name )
                            .appendTo( $el );
                    } else {
                        const reaction = await Reaction.getInstance( e.reaction_id );
                        const rsid = reaction.ruleset;
                        if ( rsid ) {
                            const ruleset = await Ruleset.getInstance( rsid );
                            $( '<a></a>' ).attr( 'href', `#rules/reaction/${reaction.id}` )
                                .text( e.reaction_name )
                                .appendTo( $el );
                            $( '<a></a>' ).attr( 'href', `#rules/ruleset/${ruleset.id}` )
                                .addClass( 'rule-ruleset-link' )
                                .text( ruleset.name )
                                .appendTo( $el );
                        } else {
                            $( '<a></a>' ).attr( 'href', `#reactions/${e.reaction_id}` )
                                .text( e.reaction_name )
                                .appendTo( $el );
                        }
                    }
                } catch ( err ) {
                    console.error( err );
                    $( '<a></a>' ).attr( 'href', `#reactions` ).text( e.reaction_name ).appendTo( $el );
                }
                $( '<div class="col-6 col-md-3 overflow-hidden text-center"></div>' )
                    .text( relativeTime( e.start_time ) )
                    .appendTo( $row );
                let st = true === e.result ? _T("OK") : String( e.result );
                st += `; ${(e.end_time||e.start_time)-e.start_time}ms`;
                $( '<div class="col-6 col-md-3 overflow-hidden text-end"></div>' )
                    .text( st )
                    .appendTo( $row );
            }
        }

        _subscribeControllers( $card ) {
            const c = api.getControllers();
            const bound_update = this.updateControllerStatus.bind( this, $card );
            for ( let cid of c ) {
                const sys = api.getEntity( `${cid}>system` );
                if ( sys ) {
                    this.subscribe( sys, bound_update );
                }
            }
        }

        async drawControllerStatus( $card ) {
            $card.addClass( 'border-info' );

            $( '.card-header h6', $card ).text( _T('Controller Status') );

            const $body = $( 'div.card-body', $card ).empty();
            $( '<div class="re-status-list">Loading...</div>' ).appendTo( $body );

            /* Subscribe for updates */
            this._subscribeControllers( $card );

            api.on( 'structure_update.' + this.id, () => {
                //console.log("Controller Status Widget: structure update; refreshing controllers");
                this._subscribeControllers( $card );
                this.updateControllerStatus( $card );
            });

            this.updateControllerStatus( $card );
        }

        async updateControllerStatus( $card ) {
            const locale = getLocale();
            // console.log("updateControllerStatus for", $card, $card.closest('.grid-stack-item').attr('gs-id'));
            const c = api.getControllers();
            let l = [];
            for ( const cid of c ) {
                const sys = api.getEntity( `${cid}>system` );
                if ( sys ) {
                    l.push( sys );
                } else {
                    console.error( "Failed to get controller's system entity for ID", cid );
                }
            }
            const $list = $( 'div.re-status-list', $card );
            $list.empty();
            l.sort( ( a, b ) => {
                var aok = a.getAttribute( 'sys_system.state' ), bok = b.getAttribute( 'sys_system.state' );
                if ( aok === bok ) {
                    return a.getName().localeCompare( b.getName(), locale, { sensitivity: 'base' } );
                }
                return aok ? 1 : -1;
            });
            for ( let sys of l ) {
                const ctrl_id = sys.getCanonicalID().replace( />.*/, "" );
                const $row = $( '<div class="row"></div>' )
                    .attr( 'id', `ctrl-${ctrl_id}` )
                    .appendTo( $list );
                $( '<div class="col"></div>' ).text( `${sys.getName()}` )
                    .attr( 'title', _T(`Controller ID: {0}`, ctrl_id ) )
                    .appendTo( $row );
                let $col = $( '<div class="col-auto re-ctrl-status"></div>' ).appendTo( $row );
                if ( ! sys ) {
                    $col.html( '<i class="bi bi-question-square text-warning"></i>' );
                } else {
                    let state = sys.getAttribute( 'sys_system.state' );
                    if ( state ) {
                        $col.html( '<i class="bi bi-arrow-up-square text-success"></i>' );
                    } else if ( false === state ) {
                        $col.html( '<i class="bi bi-arrow-down-square-fill text-danger"></i>' );
                    } else {
                        $col.html( '<i class="bi bi-question-square text-warning"></i>' );
                    }
                }
if (false) {  // eslint-disable-line no-constant-condition
                $col = $( '<div class="col-auto re-ctrl-status"></div>' ).appendTo( $row );
                if ( ! sys ) {
                    $col.html( '&nbsp;' );
                } else {
                    $( '<i class="bi bi-stop-circle text-danger re-ctrl-stop"></i>' )
                        .on( 'click', ( ev ) => {
                            const id = ( $( ev.currentTarget ).closest( 'div.row' ).attr( 'id' ) || "" ).replace( /^ctrl-/, "" );
                            api.stopController( id );
                        }).appendTo( $col );
                    $( '<i class="bi bi-play-circle text-success re-ctrl-start ms-1"></i>' )
                        .on( 'click', ( ev ) => {
                            const id = ( $( ev.currentTarget ).closest( 'div.row' ).attr( 'id' ) || "" ).replace( /^ctrl-/, "" );
                            api.startController( id );
                        }).appendTo( $col );
                    $( '<i class="bi bi-recycle text-warning re-ctrl-restart ms-1"></i>' )
                        .on( 'click', ( ev ) => {
                            const id = ( $( ev.currentTarget ).closest( 'div.row' ).attr( 'id' ) || "" ).replace( /^ctrl-/, "" );
                            api.restartController( id );
                        }).appendTo( $col );
                }
}
            }
        }

        async saveGridLayout( ev, el ) {  // eslint-disable-line no-unused-vars
            console.log( "saveGridLayout()");
            //const columns = this.grid.getColumn();
            const $stack = $( 'div.grid-stack', this.$tab );

            /* Get the current layout and remove the adder */
            /** Using this.grid.save() produces coordinates that are different from what is displayed. Apparently
             *  the ability to change the number of columns based on the window size decouples the layout coord-
             *  inates from the display coordinates: the layout is used to guide the display, but the display is
             *  made to fit and may be different. So, we will grab the actual display values here and store them.
             */
            let ll = this.grid.save( true );
console.log("SAVE DATA",ll);

            // Fix up any broken widgets before compacting.
            for (let w of ll) {
                if ( (w.id || "") === "" ) {
                    console.log("Fixup widget",w.id,"at x/y",w.x,w.y,"w/h",w.w,w.h);
                    w.id = util.getUID( "widget" );
                }
            }

            // Remove widget adder instance(s) except first, and any widgets without content
            let adder = ll.findIndex( el => el.id === "widget-adder" );
            let ix = 0;
            while ( ix < ll.length ) {
                if ( ( ll[ix].id === "widget-adder" && ix !== adder ) || "" === ( ll[ix].content || "" ).trim() ) {
                    ll.splice( ix, 1 );
                } else {
                    ++ix;
                }
            }

            // Sort the list vertically first, then horizontally; adder always sorts last
            ll.sort( (a,b) =>
                a.id === "widget.adder" ? 1 : ( b.id === "widget-adder" ? -1 :
                ( a.y === b.y ? ( a.x - b.x ) : (a.y - b.y ) ) )
            );

            // Override x,y,h,w with actual from current display. Force adder to bottom.
            let bottom = 0;
            for ( let w of ll ) {
                const $widg = $( `div.grid-stack-item[gs-id="${w.id}"]`, $stack );
                if ( $widg.length ) {
                    w.x = parseInt( $widg.attr( "gs-x" ) ) || 0;
                    w.y = parseInt( $widg.attr( "gs-y" ) ) || 0;
                    w.h = parseInt( $widg.attr( "gs-h" ) ) || 1;
                    w.w = parseInt( $widg.attr( "gs-w" ) ) || 1;
                }
                if ( "widget-adder" !== w.id ) {
                    bottom = Math.max( bottom, w.y + (w.h || 1) );
                }
            }
            adder = ll.findIndex( el => el.id === "widget-adder" );  // may have moved when sort()ing
            if ( adder >= 0 ) {
                console.log("Moving widget-adder",adder,"to y=",bottom);
                ll[ adder ].y = bottom;
                ll[ adder ].x = 0;
                ll[ adder ].h = 1;
            }
            console.log("Sorted layout", ll);

            // Finally, supply default layout if we have nothing at all.
            if ( 0 === ll.length ) {
                ll = [ ...DEFAULT_LAYOUT ];  // restore default layout
            }

            const debug_showgrid = function( rows ) {
                // Display the new grid
                for ( let [iy,row] of rows.entries() ) {
                    let line = String(iy).padStart( 2, " " ) + ": ";
                    for ( let [ix,el] of ( row || [] ).entries() ) {  // eslint-disable-line no-unused-vars
                        if ( null === el || undefined === el ) {
                            el = "";
                        }
                        line += String( el ).padStart( 3, " " );
                    }
                    console.log(line);
                }
            };

            /* Compress widget rows vertically only. Safety mechanism, we'll only do it once in a 500ms period. */
            try {
                if ( Date.now() > ( ( this.lastCompact || 0 ) + 500 ) ) {
                    this.lastCompact = Date.now();
                    let rows = [];
                    const mark = function( g, y, x, h, w, val ) {
                        for ( let iy=0; iy<h; ++iy ) {
                            rows[y+iy] = rows[y+iy] || [];
                            for ( let ix=0; ix<w; ++ix ) {
                                rows[y+iy][x+ix] = val;
                            }
                        }
                    }
                    const isempty = function( g, y, x, h, w, val ) {
                        for ( let iy=0; iy<h; ++iy ) {
                            for ( let ix=0; ix<w; ++ix ) {
                                const c = (g[y+iy]||[])[x+ix];
                                if ( undefined !== c && val !== c ) {  // cell is empty and not val
                                    return false;
                                }
                            }
                        }
                        return true;
                    }
                    // Mark grid for current positions of each widget
                    //console.log("starting layout for repositioning", ll);
                    for ( let ix=0; ix<ll.length; ++ix ) {
                        const w = ll[ix];
                        //console.log("Marking widget",ix,w.id,"y,x",w.y,w.x,"h,w",w.h,w.w,w);
                        if ( ix > 0 && ! isempty( rows, w.y, w.x, w.h || 1, w.w || 1, ix ) ) {
                            // Widget does not fit in this position. This is related to a likely bug in gridstack
                            // that occasionally will place widgets over one-another when in single column mode.
                            // I haven't made that a reproducible case yet for them, but work around is here.
                            //console.log("Widget",ix,w.id,"does not fit at y,x",w.y,w.x,"h,w",w.h,w.w);
                            //debug_showgrid( rows );
                            const bottom = ll[ix-1].y + ll[ix-1].h;
                            for ( let k=ix; k<ll.length; ++k ) {
                                ll[k].y += bottom;
                            }
                        }
                        mark( rows, w.y, w.x, w.h || 1, w.w || 1, ix );
                    }
                    console.log("after marking");
                    debug_showgrid( rows );

                    // Now for each widget, try to move it up as far as we can
                    for ( let [ix,w] of ll.entries() ) {
                        //console.log(ix,"checking",w.id,"at row",w.y,'col',w.x,'height',w.h,'width',w.w);
                        let orig_y = w.y;
                        while ( w.y > 0 ) {
                            // Check only row above -- not optimal, but reliable.
                            if ( isempty( rows, w.y-1, w.x, 1, w.w || 1, ix ) ) {
                                w.y = w.y - 1;
                            } else {
                                //console.log("isempty",w.y-1,w.x,1,w.w,"is FALSE");
                                break;
                            }
                        }
                        if ( orig_y != w.y ) {
                            // Moving up! Unmark old position and mark new.
                            //console.log( ix,'moving',w.id,'from row',orig_y,'to',w.y );
                            mark( rows, orig_y, w.x, w.h || 1, w.w || 1, undefined );
                            mark( rows, w.y, w.x, w.h || 1, w.w || 1, ix );
                        }
                    }
                    console.log("after compacting");
                    debug_showgrid( rows );

                    this.grid.load( ll );  // recommended in preference to serial update() calls
                    this.grid.movable( '.grid-stack-item', true );

                }
            } catch ( err ) {
                console.error( "Failed to compress widget layout:", err );
            } finally {
                setTimeout( () => { this.placeWidgetAdder( $stack ); }, 100 );
            }

            console.log( "Saving modified status layout to localStorage", ll );
            localStorage.setItem( 'reactor-status-layout', JSON.stringify( ll, (k,v) => { return "content" === k ? clipSavedContent( v ) : v; } ) );
        }

        loadGridLayout() {
            try {
                let layout = JSON.parse( localStorage.getItem( 'reactor-status-layout' ) );
                if ( Array.isArray( layout ) && layout.length > 0 ) {
                    console.log( "Using status widget layout loaded from localStorage" );
                    return layout;
                }
                console.error( "Invalid status widget layout; ignoring", layout );
            } catch ( err ) {
                console.error( err );
            }
            console.log( "Returning default status widget layout" );
            return [ ...DEFAULT_LAYOUT ];
        }
    }

    return {
        "init": function( $main ) {
            if ( ! tabInstance ) {
                tabInstance = new StatusTab( $main );
            }

            /* Set up CTRL-SHIFT-CLICK in tab to reset layout */
            const $tab = tabInstance.getTabElement();
            $tab.on( 'click', (ev,ui) => {  // eslint-disable-line no-unused-vars
                if ( ev.shiftKey && ev.ctrlKey ) {
                    if ( confirm( 'Reset status widget layout?' ) ) {
                        localStorage.setItem( 'reactor-status-layout', JSON.stringify( DEFAULT_LAYOUT ) );
                    }
                }
            });
        },
        "tab": () => tabInstance.getTabElement()
    };
})( jQuery );
