Dashboard: - Plain-count stat tiles (Running/Stopped/Pods/Images/Volumes/Networks) separated from a single "Resource Usage" card (CPU/Memory/Swap/Storage meter rows) instead of forcing both into one tile grid, which produced awkward spanning-tile/dead-cell layouts. - Fixed CPU usage never changing (libpod's own cpuUtilization is computed once and never resampled) by computing it from /proc/stat deltas instead. - Fixed memory usage reading far too high by using /proc/meminfo's MemAvailable instead of libpod's raw (non-reclaimable-aware) memFree. - Added an Autostart Queue table reusing podman-autostart.sh's own failure-counter files. - Dashboard and Containers now auto-refresh every ~2s (paused when the tab is hidden or a modal is open). Toasts: - Real success/warn/error/info toast notifications replacing every alert() used for one-way feedback, across every panel. Container detail modal: - 5 new tabs: Resources, Logs, Console, Events, Healthcheck. Containers panel: - Folders to group containers (name + icon), stored in the plugin's own folders.json — a folder's header always shows an icon+name+status chip per member, matching Unraid's own Docker page folders. "Move to Folder" becomes "Remove from Folder" once a container is already grouped. - Containers can carry an icon URL and a WebUI URL (small button next to the name), both stored as container labels and auto-filled from templates where applicable. - Settings: an "Add container" control for the Autostart order table. Fixes: - Context menus now measure their own rendered size and flip above the anchor when there isn't room below, instead of running off-screen. - Containers table now uses table-layout:fixed with explicit column widths — auto layout was shifting every column (and the header) on every folder expand/collapse, and briefly again when a flex wrapper was mistakenly placed directly on a <td>. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
522 lines
20 KiB
JavaScript
522 lines
20 KiB
JavaScript
/**
|
||
* javascript/app.js
|
||
*
|
||
* Shared runtime for the Podman plugin page: the AJAX helper every panel
|
||
* module uses to talk to webui/plugins/podman/ajax/*.php, small DOM
|
||
* utilities to avoid repeating the same escaping/formatting logic in ten
|
||
* places, and the sub-tab router that shows/hides panels and lazily
|
||
* initializes each one's module the first time it's opened.
|
||
*
|
||
* Loaded first (before any panel module) — see Podman.page.
|
||
*/
|
||
window.Podman = (function () {
|
||
'use strict';
|
||
|
||
const BASE = '/plugins/podman/ajax/';
|
||
|
||
/**
|
||
* Calls one ajax/<file>.php?action=<action> endpoint and resolves with
|
||
* response.data, or rejects with an Error carrying the server's message
|
||
* — every ajax/*.php endpoint replies with the same {ok, data|error}
|
||
* envelope (see include/helpers.php's podman_json_response/_error), so
|
||
* this one function is the only place that envelope shape is known.
|
||
*
|
||
* @param {string} file e.g. "containers"
|
||
* @param {string} action e.g. "list"
|
||
* @param {('GET'|'POST')} method
|
||
* @param {object|null} body sent as JSON for POST
|
||
* @param {object} query extra query-string params (e.g. {id: "..."})
|
||
*/
|
||
function call(file, action, method, body, query) {
|
||
const params = new URLSearchParams(Object.assign({ action: action }, query || {}));
|
||
const url = BASE + file + '.php?' + params.toString();
|
||
|
||
const opts = { method: method, headers: {} };
|
||
if (body !== undefined && body !== null) {
|
||
opts.headers['Content-Type'] = 'application/json';
|
||
opts.body = JSON.stringify(body);
|
||
}
|
||
// Unraid's own webGui/include/local_prepend.php (auto_prepend_file on
|
||
// every PHP request, not something this plugin controls) kills any
|
||
// POST request with no output at all unless it carries the page's
|
||
// CSRF token — either as a "csrf_token" POST field or this header.
|
||
// `csrf_token` itself is a global var HeadInlineJS.php sets on every
|
||
// Unraid page before plugin JS loads (verified live: without this
|
||
// header, every mutating action failed with "JSON.parse: unexpected
|
||
// end of data", i.e. an empty response body from csrf_terminate()).
|
||
if (method === 'POST' && typeof window.csrf_token === 'string') {
|
||
opts.headers['X-CSRF-Token'] = window.csrf_token;
|
||
}
|
||
|
||
return fetch(url, opts)
|
||
.then(function (res) {
|
||
return res.json().then(function (envelope) {
|
||
if (!envelope.ok) {
|
||
throw new Error(envelope.error || ('Request failed (' + res.status + ')'));
|
||
}
|
||
return envelope.data;
|
||
});
|
||
});
|
||
}
|
||
|
||
function get(file, action, query) {
|
||
return call(file, action, 'GET', null, query);
|
||
}
|
||
|
||
function post(file, action, body) {
|
||
return call(file, action, 'POST', body || {}, {});
|
||
}
|
||
|
||
// --- DOM helpers ---------------------------------------------------------
|
||
|
||
function escapeHtml(value) {
|
||
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
|
||
function el(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (!bytes || bytes <= 0) return '0 B';
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||
const value = bytes / Math.pow(1024, i);
|
||
return (value >= 100 || i === 0 ? value.toFixed(0) : value.toFixed(1)) + ' ' + units[i];
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
if (seconds == null) return '—';
|
||
if (seconds < 60) return seconds + 's';
|
||
const days = Math.floor(seconds / 86400);
|
||
const hours = Math.floor((seconds % 86400) / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
if (days > 0) return days + 'd ' + hours + 'h';
|
||
if (hours > 0) return hours + 'h ' + minutes + 'm';
|
||
return minutes + 'm';
|
||
}
|
||
|
||
function formatRelativeTime(unixSeconds) {
|
||
if (!unixSeconds) return '—';
|
||
const diff = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds);
|
||
if (diff < 60) return 'just now';
|
||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||
return Math.floor(diff / 86400) + 'd ago';
|
||
}
|
||
|
||
/** Status string (from libpod's container "State") -> chip color class. */
|
||
function stateChipClass(state) {
|
||
switch (state) {
|
||
case 'running': return 'podman-chip-good';
|
||
case 'paused': return 'podman-chip-warn';
|
||
case 'exited':
|
||
case 'created': return 'podman-chip-neutral';
|
||
default: return 'podman-chip-bad';
|
||
}
|
||
}
|
||
|
||
function loadingRow(colspan, label) {
|
||
return '<tr><td colspan="' + colspan + '" class="podman-loading">' + escapeHtml(label || 'Loading…') + '</td></tr>';
|
||
}
|
||
|
||
function errorRow(colspan, message) {
|
||
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
||
}
|
||
|
||
// --- Toast notifications ----------------------------------------------------
|
||
//
|
||
// Replaces alert() for one-way feedback ("Saved.", "Removed 3 image(s)",
|
||
// "Save failed: ..."). Confirmations stay native confirm() — a toast is
|
||
// for telling the user something happened, not for asking them a
|
||
// yes/no question. Command output that can run to hundreds of lines
|
||
// (e.g. `podman compose up`) stays in the existing openLogModal()
|
||
// pattern instead — a toast has to stay short and auto-dismiss, which
|
||
// doesn't fit a scrolling log.
|
||
|
||
const TOAST_ICON = { success: '✓', warn: '!', error: '✕', info: 'ℹ' };
|
||
const TOAST_DURATION_MS = { success: 4000, warn: 5000, error: 7000, info: 4000 };
|
||
let toastContainer = null;
|
||
|
||
function toastRoot() {
|
||
if (!toastContainer) {
|
||
toastContainer = document.createElement('div');
|
||
toastContainer.className = 'podman-toast-container';
|
||
(document.querySelector('.podman-plugin') || document.body).appendChild(toastContainer);
|
||
}
|
||
return toastContainer;
|
||
}
|
||
|
||
/**
|
||
* @param {string} message
|
||
* @param {('success'|'warn'|'error'|'info')} [type='info']
|
||
*/
|
||
function toast(message, type) {
|
||
const kind = TOAST_ICON[type] ? type : 'info';
|
||
const root = toastRoot();
|
||
|
||
const node = document.createElement('div');
|
||
node.className = 'podman-toast podman-toast-' + kind;
|
||
node.innerHTML =
|
||
'<span class="ico">' + TOAST_ICON[kind] + '</span>' +
|
||
'<span class="msg"></span>' +
|
||
'<button type="button" class="close" aria-label="Dismiss">✕</button>';
|
||
node.querySelector('.msg').textContent = message;
|
||
root.appendChild(node);
|
||
|
||
let dismissed = false;
|
||
function dismiss() {
|
||
if (dismissed) return;
|
||
dismissed = true;
|
||
node.classList.add('leaving');
|
||
// Not animationend — that never fires when prefers-reduced-motion
|
||
// disables the animation (podman.css sets animation:none for it),
|
||
// which would leave the toast stuck on screen forever.
|
||
setTimeout(function () { node.remove(); }, 160);
|
||
}
|
||
node.querySelector('.close').addEventListener('click', dismiss);
|
||
setTimeout(dismiss, TOAST_DURATION_MS[kind]);
|
||
}
|
||
|
||
// --- Modal form dialog -----------------------------------------------------
|
||
|
||
/**
|
||
* Shows a small form modal in place of browser-native prompt()/confirm()
|
||
* — needed for any action that takes more than one related value (e.g.
|
||
* "New Volume" wants a name AND an optional host path together; chaining
|
||
* prompt() calls for that is both bad UX and can't show both fields at
|
||
* once, or offer a hint under the path field explaining what it does).
|
||
*
|
||
* @param {object} opts
|
||
* @param {string} opts.title
|
||
* @param {Array<{name:string, label:string, placeholder?:string, hint?:string, required?:boolean}>} opts.fields
|
||
* @param {string} [opts.submitLabel]
|
||
* @param {(values: Object<string,string>) => Promise<any>} opts.onSubmit
|
||
* Called with {fieldName: value}. Rejecting keeps the modal open and
|
||
* shows the error inline; resolving closes it.
|
||
*/
|
||
function openFormModal(opts) {
|
||
const backdrop = document.createElement('div');
|
||
backdrop.className = 'podman-modal-backdrop';
|
||
|
||
const fieldsHtml = opts.fields.map(function (f) {
|
||
return '' +
|
||
'<div class="podman-modal-field">' +
|
||
'<label for="podman-modal-' + f.name + '">' + escapeHtml(f.label) + '</label>' +
|
||
'<input type="text" id="podman-modal-' + f.name + '" name="' + f.name + '"' +
|
||
(f.placeholder ? ' placeholder="' + escapeHtml(f.placeholder) + '"' : '') + '>' +
|
||
(f.hint ? '<div class="hint">' + escapeHtml(f.hint) + '</div>' : '') +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
backdrop.innerHTML = '' +
|
||
'<div class="podman-modal" role="dialog" aria-modal="true">' +
|
||
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title) + '</h3></div>' +
|
||
'<form class="podman-modal-body">' + fieldsHtml + '</form>' +
|
||
'<div class="podman-modal-actions">' +
|
||
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
|
||
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' +
|
||
escapeHtml(opts.submitLabel || 'Create') + '</button>' +
|
||
'</div></div>';
|
||
|
||
// Appended inside .podman-plugin, not document.body: the --surface/
|
||
// --border/etc. custom properties this modal's CSS relies on are
|
||
// scoped to .podman-plugin (see podman.css's token strategy comment),
|
||
// so a modal appended to body would resolve none of them — verified
|
||
// live: the backdrop dimming and card background were both missing,
|
||
// only the (inherited-from-body) text was visible. position:fixed
|
||
// still overlays the full viewport regardless of this nesting.
|
||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||
|
||
const firstInput = backdrop.querySelector('input');
|
||
if (firstInput) firstInput.focus();
|
||
|
||
function close() {
|
||
backdrop.remove();
|
||
}
|
||
|
||
function submit() {
|
||
const values = {};
|
||
opts.fields.forEach(function (f) {
|
||
values[f.name] = backdrop.querySelector('#podman-modal-' + f.name).value.trim();
|
||
});
|
||
for (const f of opts.fields) {
|
||
if (f.required && !values[f.name]) {
|
||
showError('"' + f.label + '" is required.');
|
||
return;
|
||
}
|
||
}
|
||
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
||
submitBtn.disabled = true;
|
||
Promise.resolve(opts.onSubmit(values)).then(close).catch(function (err) {
|
||
submitBtn.disabled = false;
|
||
showError(err.message || String(err));
|
||
});
|
||
}
|
||
|
||
function showError(message) {
|
||
let box = backdrop.querySelector('.podman-modal-error');
|
||
if (!box) {
|
||
box = document.createElement('div');
|
||
box.className = 'podman-modal-error';
|
||
backdrop.querySelector('.podman-modal-body').appendChild(box);
|
||
}
|
||
box.textContent = message;
|
||
}
|
||
|
||
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
|
||
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
|
||
backdrop.querySelector('form').addEventListener('submit', function (e) {
|
||
e.preventDefault();
|
||
submit();
|
||
});
|
||
backdrop.addEventListener('click', function (e) {
|
||
if (e.target === backdrop) close();
|
||
});
|
||
document.addEventListener('keydown', function onKey(e) {
|
||
if (e.key === 'Escape') {
|
||
close();
|
||
document.removeEventListener('keydown', onKey);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Small modal with a scrolling monospace log pane — for actions that run
|
||
* several steps in sequence (checking/updating containers) where a plain
|
||
* confirm()/alert() at the very end leaves the user with no feedback
|
||
* that anything is happening while it runs. Returns {log, done} rather
|
||
* than closing itself, since the caller knows when the whole sequence
|
||
* (not just one call) has actually finished.
|
||
*
|
||
* @param {string} title
|
||
* @returns {{log: (line: string) => void, done: (closeLabel?: string) => void}}
|
||
*/
|
||
function openLogModal(title) {
|
||
const backdrop = document.createElement('div');
|
||
backdrop.className = 'podman-modal-backdrop';
|
||
backdrop.innerHTML = '' +
|
||
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
|
||
'<div class="podman-modal-head"><h3>' + escapeHtml(title) + '</h3></div>' +
|
||
'<div class="podman-modal-body"><div class="podman-log-pane" id="podman-log-modal-pane"></div></div>' +
|
||
'<div class="podman-modal-actions"><button type="button" class="podman-btn podman-btn-primary" data-role="close" disabled>Working…</button></div>' +
|
||
'</div>';
|
||
|
||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||
const pane = backdrop.querySelector('#podman-log-modal-pane');
|
||
const closeBtn = backdrop.querySelector('[data-role="close"]');
|
||
|
||
function close() { backdrop.remove(); }
|
||
closeBtn.addEventListener('click', close);
|
||
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
|
||
document.addEventListener('keydown', function onKey(e) {
|
||
if (e.key === 'Escape' && !closeBtn.disabled) { close(); document.removeEventListener('keydown', onKey); }
|
||
});
|
||
|
||
function log(line) {
|
||
const row = document.createElement('div');
|
||
row.textContent = line;
|
||
pane.appendChild(row);
|
||
pane.scrollTop = pane.scrollHeight;
|
||
}
|
||
|
||
function done(closeLabel) {
|
||
closeBtn.disabled = false;
|
||
closeBtn.textContent = closeLabel || 'Close';
|
||
}
|
||
|
||
return { log: log, done: done };
|
||
}
|
||
|
||
/**
|
||
* Small anchored dropdown menu — used for secondary per-row actions
|
||
* (pause/kill/rename/...) that would otherwise clutter a table row with
|
||
* one icon button each. Only one menu is ever open at a time.
|
||
*
|
||
* @param {HTMLElement} anchorEl button the menu opens from/closes on
|
||
* @param {Array<{label:string, danger?:boolean, disabled?:boolean, onClick?:Function}|'separator'>} items
|
||
*/
|
||
let openMenuCloser = null;
|
||
function openContextMenu(anchorEl, items) {
|
||
if (openMenuCloser) {
|
||
openMenuCloser();
|
||
return;
|
||
}
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'podman-context-menu';
|
||
menu.innerHTML = items.map(function (item) {
|
||
if (item === 'separator') return '<div class="podman-context-menu-sep"></div>';
|
||
return '<button type="button" class="' + (item.danger ? 'danger' : '') + '"' +
|
||
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
|
||
}).join('');
|
||
|
||
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
|
||
|
||
// Viewport-relative (see the "position: fixed" comment on
|
||
// .podman-context-menu in podman.css) — no scrollY/scrollX added.
|
||
// Measured AFTER appending (not assumed) since the menu's height
|
||
// varies with its item count — a long menu (e.g. a container's row
|
||
// menu with Details/Pause/Kill/Rename/Edit/Remove) opened from a row
|
||
// near the bottom of the viewport used to run off-screen with no way
|
||
// to reach its last few items. Flips above the anchor instead when
|
||
// there isn't enough room below, and clamps horizontally the same way.
|
||
const rect = anchorEl.getBoundingClientRect();
|
||
const menuRect = menu.getBoundingClientRect();
|
||
const margin = 8;
|
||
|
||
let top = rect.bottom + 4;
|
||
if (top + menuRect.height > window.innerHeight - margin) {
|
||
top = rect.top - menuRect.height - 4;
|
||
}
|
||
top = Math.max(margin, top);
|
||
|
||
let left = rect.right - menuRect.width;
|
||
left = Math.min(left, window.innerWidth - menuRect.width - margin);
|
||
left = Math.max(margin, left);
|
||
|
||
menu.style.top = top + 'px';
|
||
menu.style.left = left + 'px';
|
||
|
||
// menu.children includes the separator <div>s too, so indexing into it
|
||
// directly (by a counter that only advances for real items) drifts by
|
||
// one after every separator — e.g. "Remove" (after a separator) ended
|
||
// up wired to the separator <div> instead of its own <button>, so
|
||
// clicking it did nothing. querySelectorAll('button') only ever
|
||
// returns the actual buttons, in the same order as the non-separator
|
||
// items, so indexing into that stays aligned regardless of separators.
|
||
const buttons = menu.querySelectorAll('button');
|
||
let buttonIndex = 0;
|
||
items.forEach(function (item) {
|
||
if (item === 'separator') return;
|
||
const btn = buttons[buttonIndex];
|
||
buttonIndex++;
|
||
if (item.disabled) return;
|
||
btn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
close();
|
||
if (item.onClick) item.onClick();
|
||
});
|
||
});
|
||
|
||
function close() {
|
||
menu.remove();
|
||
document.removeEventListener('click', onOutsideClick);
|
||
document.removeEventListener('keydown', onKey);
|
||
openMenuCloser = null;
|
||
}
|
||
function onOutsideClick(e) {
|
||
if (!menu.contains(e.target)) close();
|
||
}
|
||
function onKey(e) {
|
||
if (e.key === 'Escape') close();
|
||
}
|
||
|
||
openMenuCloser = close;
|
||
// Deferred so the click that opened the menu doesn't immediately
|
||
// trigger onOutsideClick via event bubbling.
|
||
setTimeout(function () {
|
||
document.addEventListener('click', onOutsideClick);
|
||
document.addEventListener('keydown', onKey);
|
||
}, 0);
|
||
}
|
||
|
||
// --- Panel router ----------------------------------------------------------
|
||
|
||
const panelModules = {};
|
||
const initialized = {};
|
||
|
||
/** Called by each panel's own JS file (e.g. containers.js) to register itself. */
|
||
function registerPanel(name, module) {
|
||
panelModules[name] = module;
|
||
}
|
||
|
||
function activatePanel(name) {
|
||
document.querySelectorAll('.podman-subnav button').forEach(function (btn) {
|
||
btn.classList.toggle('active', btn.dataset.panel === name);
|
||
});
|
||
document.querySelectorAll('.podman-panel').forEach(function (panel) {
|
||
panel.classList.toggle('active', panel.id === 'podman-panel-' + name);
|
||
});
|
||
|
||
const module = panelModules[name];
|
||
if (!module) return;
|
||
|
||
if (!initialized[name]) {
|
||
initialized[name] = true;
|
||
if (typeof module.init === 'function') module.init();
|
||
} else if (typeof module.refresh === 'function') {
|
||
module.refresh();
|
||
}
|
||
}
|
||
|
||
function boot() {
|
||
const subnav = document.querySelector('.podman-subnav');
|
||
if (!subnav) return;
|
||
|
||
subnav.addEventListener('click', function (e) {
|
||
const btn = e.target.closest('button[data-panel]');
|
||
if (btn) activatePanel(btn.dataset.panel);
|
||
});
|
||
|
||
const refreshBtn = el('podman-refresh-all');
|
||
if (refreshBtn) {
|
||
refreshBtn.addEventListener('click', function () {
|
||
const active = document.querySelector('.podman-subnav button.active');
|
||
if (active) activatePanel(active.dataset.panel);
|
||
});
|
||
}
|
||
|
||
// Activate whichever panel is marked active in the initial HTML
|
||
// (Dashboard, by default — see Podman.page).
|
||
const initial = document.querySelector('.podman-subnav button.active');
|
||
activatePanel(initial ? initial.dataset.panel : 'dashboard');
|
||
|
||
setInterval(autoRefreshTick, AUTO_REFRESH_INTERVAL_MS);
|
||
}
|
||
|
||
// Only panels that opt in via `autoRefresh: true` (Dashboard, Containers)
|
||
// get polled — most panels (Settings, Compose, Terminal, ...) have
|
||
// in-progress forms or connections an unexpected refresh would disrupt.
|
||
// Paused while the tab is hidden (nothing to look at) and while any
|
||
// modal is open (a full-panel re-render mid-edit would be jarring),
|
||
// rather than fighting those cases with more state.
|
||
const AUTO_REFRESH_INTERVAL_MS = 2000;
|
||
|
||
function autoRefreshTick() {
|
||
if (document.hidden) return;
|
||
if (document.querySelector('.podman-modal-backdrop')) return;
|
||
|
||
const activeBtn = document.querySelector('.podman-subnav button.active');
|
||
if (!activeBtn) return;
|
||
const name = activeBtn.dataset.panel;
|
||
const module = panelModules[name];
|
||
if (module && module.autoRefresh && initialized[name] && typeof module.refresh === 'function') {
|
||
module.refresh();
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', boot);
|
||
|
||
return {
|
||
get: get,
|
||
post: post,
|
||
escapeHtml: escapeHtml,
|
||
el: el,
|
||
formatBytes: formatBytes,
|
||
formatDuration: formatDuration,
|
||
formatRelativeTime: formatRelativeTime,
|
||
stateChipClass: stateChipClass,
|
||
loadingRow: loadingRow,
|
||
errorRow: errorRow,
|
||
toast: toast,
|
||
openFormModal: openFormModal,
|
||
openLogModal: openLogModal,
|
||
openContextMenu: openContextMenu,
|
||
registerPanel: registerPanel,
|
||
activatePanel: activatePanel,
|
||
};
|
||
})();
|