Sisense Community logo
     
    • Community Feedback
    • Chapters
    • Events
    • Forums
      • Help and How To
      • Product Feedback Forum
      • Strategy & Use Cases
    • Blogs
    • KB Docs
      • KB Docs
      • Add-Ons & Plug-Ins
      • APIs
      • Best Practices
      • Blox
      • CDT
      • Cloud Managed Service
      • Data Models
      • Data Sources
      • Embedding Analytics
      • How-Tos & FAQs
      • Onboarding
      • PySisense
      • Security
      • Sisense Administration
      • Sisense Intelligence & AI
      • Troubleshooting
      • Widget & Dashboard Scripts
    • Support
    • Learning
      • Sisense Academy: Free Courses and Certifications
      • Official Developer Documentation
      • Official Product Documentation
      • Official Sisense Youtube Channel
      • Sisense Compose SDK Playground
      • Official Sisense Discord
    • Use Case Gallery
    •      
    Discussions
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   
    Discussions
    • TagsChevronRightIcon
    User Adoption
    • Blog banner
      • Widget & Dashboard ScriptsChevronRightIcon

      Hiding Widgets if a Widget Has No Results [Linux]

                                                                                                               

      Hiding Widgets if a Widget Has No Results [Linux] Introduction A common dashboard design requirement is hiding widgets that have no data. This article includes a widget script that conditionally hides an indicator widget when its primary value is empty or represents an N/A like value. This behavior is commonly requested when dashboard filters or formulas result in no meaningful value and the widget should be visually hidden rather than showing an empty or zero indicator. The article also includes an alternative dashboard-level approach that hides widgets based on filter selections, without inspecting widget query results. This alternative is derived from the linked external blog post . This is applicable to both on-cloud and on-prem Sisense in all recent Sisense versions. Use Case Customers often want indicator widgets to disappear when their calculated value is not meaningful. Common examples include: Filters resulting in no matching data Calculations returning N/A, null, or empty values Conditional metrics that only apply to certain filter selections Rather than showing an empty indicator, this approach hides the widget entirely and restores it automatically when the value becomes valid again. Layout Considerations For best visual results, it is ideal to use one of these two layouts for the indicator widgets that may be hidden: Place the indicator widget in its own dashboard row Place it at the end of a row Hiding a widget via a script does not automatically resize or reflow other widgets on the same row. Step by Step Guide Widget Script to Hide Widget Based on Indicator Value This widget script is applied directly to the indicator widget. It evaluates the widget’s primary value after each render and hides or shows the widget accordingly. Behavior If the primary value is null, empty, or an N/A like string, the widget container is set to display: none. When the value becomes valid and not null again, the widget is restored to visibility and redrawn to ensure correct indicator rendering. A guard variable prevents infinite redraw loops, since a redraw triggers the widget ready event. A debug flag allows optional console logging when needed. Widget Script /** * Hide widget when its primary indicator value is empty or N/A-like. * * Behavior: * - If the value is empty, set the widget container to display "none". * - If the returned value becomes a number or valid value, restore display style to original and redraw * - A redraw triggers "ready" again, so a guard variable prevents a redraw loop. * * Debug: * - Set debug variable = true to enable console logging to track script status. * * For best results use on indicator in own row or at end of row, if other widgets exist on row, empty space will appear in dashboard */ widget.on("ready", function () { function run() { var debug; var suppressNextReady; var hideValues; debug = false; suppressNextReady = false; hideValues = [ "n/a", "#n/a", "na", "none", "null", "undefined" ]; // Function to turn console logging on or off function log(message, data) { if (!debug) { return; } if (data === undefined) { console.log("[hide-empty-indicator] " + message); return; } console.log("[hide-empty-indicator] " + message, data); } // Widget CSS selector function getWidgetElement() { return document.querySelector('widget[widgetid="' + widget.oid + '"]'); } // Get value of primary indicator value function getPrimaryValue() { if ( widget.queryResult && widget.queryResult.value && widget.queryResult.value.data !== undefined ) { return widget.queryResult.value.data; } try { if ( widget.queryResult && widget.queryResult.data && widget.queryResult.data.length ) { return widget.queryResult.data[0][0]; } } catch (e) { log("Value read failed for data[0][0].", e); } if (widget.queryResult && Array.isArray(widget.queryResult)) { if (widget.queryResult.length && widget.queryResult[0].length) { if (widget.queryResult[0][0]) { return widget.queryResult[0][0].Value; } } } return null; } function shouldHide(value) { var text; var normalized; if (value === null || value === undefined) { return true; } text = String(value).trim(); if (!text) { return true; } normalized = text.replace(/\\/g, "/").toLowerCase(); return hideValues.indexOf(normalized) !== -1; } function isElementHidden(element) { if (!element) { return false; } return element.style.display === "none"; } function hideWidget(element) { if (!element) { return; } if (element.style.display === "none") { return; } element.style.display = "none"; log("Hid widget due to empty/N/A-like value."); } function showWidgetAndRedrawIfNeeded(element) { var wasHidden; if (!element) { return; } wasHidden = isElementHidden(element); element.style.display = ""; if (!wasHidden) { log("Widget already visible."); return; } if (typeof widget.redraw !== "function") { log("Widget restored, redraw not available."); return; } suppressNextReady = true; log("Widget restored, triggering redraw."); widget.redraw(); } function applyRule() { var element; var primaryValue; element = getWidgetElement(); if (!element) { log("Widget container element not found."); return; } if (widget.queryResult === undefined) { log("queryResult is not available yet, keeping widget visible."); element.style.display = ""; return; } primaryValue = getPrimaryValue(); log("Primary value evaluated.", primaryValue); if (shouldHide(primaryValue)) { hideWidget(element); return; } showWidgetAndRedrawIfNeeded(element); } function onReady() { if (suppressNextReady) { suppressNextReady = false; log("Ready fired after redraw, applying rule without redraw."); applyRule(); return; } applyRule(); } onReady(); } run(); }); Notes The script uses display: none instead of jQuery hide or show to avoid layout and rendering issues with indicator widgets. Redraw is triggered only when restoring visibility, not when hiding. The script relies only on the widget ready event, which fires again after redraw and filter changes. Dashboard Script to Hide Widgets Based on Filter Selections As an alternative, widgets can be hidden purely based on filter selections, without checking whether the widget returns data. This approach is useful when visibility rules are deterministic based on filters. This method uses a dashboard script and CSS classes to hide widget containers. Example Dashboard Script dashboard.on('filterschanged', function (se, ev) { let filterName = 'Region' //mapping of filter items and widgets to be hidden. //if selected filter item is not available in the list, widgets in 'default' key will be hidden let itemWidgetMapping = { 'Midwest':['6390b5a285a029002e9e2ad6'], 'South': ['6238887ba77683002ea4425b'], 'West':['6390b5a285a029002e9e2ad6', '6238887ba77683002ea4425b'], 'default':[] } selectedFilter = ev.items.find(el=>el.jaql.title == filterName) let selectedItem = 'default' if(selectedFilter && selectedFilter.jaql.filter.members) selectedItem = selectedFilter.jaql.filter.members[0] //unhide all widgets first and then hide widgets based on selected filter $(`widget`).closest('.dashboard-layout-subcell-host').removeClass('dontshowme-parent') if(selectedItem in itemWidgetMapping){ for (const [key, value] of Object.entries(itemWidgetMapping)) { if(key == selectedItem){ itemWidgetMapping[key].forEach(function (item, index) { $(`widget[widgetid="${item}"]`).closest('.dashboard-layout-subcell-host').addClass('dontshowme-parent') }); } } } else{ itemWidgetMapping['default'].forEach(function (item, index) { $(`widget[widgetid="${item}"]`).closest('.dashboard-layout-subcell-host').addClass('dontshowme-parent') }); } }); Choosing the Right Approach Generally the widget script is best suited when: Visibility depends on whether data is returned The indicator value can be empty due to calculations or filters Generally the dashboard script is best suited when: Visibility depends only on filter selections Centralized control over multiple widgets is required These approaches are alternatives and should not be used simultaneously for the same widgets. Conclusion Hiding indicator widgets based on their returned value can potentially improve dashboard clarity and user experience. The widget script approach provides result aware behavior, while the dashboard script approach offers deterministic, filter based control. Both methods are powerful tools for customizing dashboard widget visibility. Two Full Row Indicator Widgets, both visible First Row Indicator Widget is now hidden, by script, due to no data Two Indicators in one row, both visible Second Indicator is now hidden by script, due to no data Disclaimer: This post outlines a potential custom workaround for a specific use case or provides instructions regarding a specific task. The solution may not work in all scenarios or Sisense versions, so we strongly recommend testing it in your environment before deployment. If you need further assistance with this, please let us know.

      Jeremy Friedel
      Jeremy FriedelPosted 1 week ago
      0
               
    • Blog banner
      • Add-ons & Plug-InsChevronRightIcon

      Managing Sisense Plugin States via the REST API

                                                                               

      Managing Sisense Plugin States via the REST API Overview Manually enabling and disabling Sisense plugins through the admin interface can become time consuming, particularly when keeping multiple Sisense server instances in a consistent state, switching active plugin lists quickly for testing, restoring a previous known working plugin list after an upgrade or support session, or iterating on a plugin during development where having only one plugin active significantly reduces build time for any plugin changes to become active. This article describes a Python command line (CLI) tool that manages Sisense plugin states via the Sisense REST API, using a simple configuration file as the config. The tool supports enabling and disabling plugins, isolating (enabling only one and disabling all others) a single plugin for faster development builds, saving and restoring named snapshots of plugin state, and previewing changes before applying them. This tool does not install or uninstall plugins, and does not update plugins or upgrade/downgrade or modify the code within the plugins. The script, configuration file, and dependencies are included in this article. Sisense instances often have many plugins active, with the enabled list changing over time. Common scenarios include: Plugins being left enabled or disabled after support sessions or testing, or enabling or disabling all plugins The need to quickly reach a server plugin state with no plugins enabled, to isolate whether a plugin is causing a potential issue Maintaining a consistent plugin list across multiple Sisense environments or after upgrades Needing to roll back to a previous plugins active state after a change is potentially creating issues (This tool does not rollback plugin versions, only active plugin lists) Performing these operations manually through the Sisense UI admin panel is practical for occasional changes, but becomes inefficient when the changes are frequent, when many plugins are involved, or when the same configuration needs to be applied reliably and repeatably. Description The plugin manager script reads an enable_plugins list from a configuration file and syncs the Sisense instance to match via the REST API. The script: Enables plugins listed in enable_plugins in the configuration file Optionally disables plugins listed in disable_plugins Leaves all other plugins in their current state by default Saves a timestamped snapshot of the current plugin state before applying any changes, providing an automatic rollback point Supports restoring any saved snapshot to return to a previous state A strict mode is also available, which disables any currently enabled plugin that is not listed in enable_plugins , making the instance exactly match the configuration file. The script also supports previewing what would change without making any API calls, and can save named snapshots for later reference or comparison. Plugin name matching Plugin names in the configuration file accept either the short form ( SwitchDimension ) or the full form ( plugin-SwitchDimension ). Matching is case-insensitive. This applies both to the configuration file and to command line arguments. Prerequisites Python 3.7 or later Network access to the Sisense instance (The tool runs remotely and does not need to be installed directly on the server, or SSH access) A Sisense API token, which can be generated from Admin > REST API > v1.0 > GET /authentication/tokens/api Setup Install the required Python packages: pip install requests pyyaml Create a config.yaml and fill in the Sisense instance URL and API token: sisense: url: https://your-sisense-instance.com token: YOUR_API_TOKEN_HERE Add the plugins to enable to the enable_plugins list. See the Configuration reference section for all available settings. The script is then ready to run. Configuration reference sisense: url: https://your-sisense-instance.com token: YOUR_API_TOKEN_HERE # Admin → REST API → v1.0 → GET /authentication/tokens/api bulk: true # set false to update plugins one at a time instead of a single batch request # Plugins to enable. # Accepts short names (SwitchDimension) or full names (plugin-SwitchDimension). # Matching is case-insensitive. # # Default behavior: enable listed plugins; leave every other plugin unchanged. # With --strict: also disable any currently-enabled plugin NOT in this list, # making the instance exactly match this config. enable_plugins: - examplePlugin # Plugins to explicitly disable (optional). # Only needed in the default sync (no --strict), where unlisted plugins are # left alone. Listing a plugin here ensures it stays off and documents the # intentional exclusion. # disable_plugins: # - anotherExamplePlugin enable_plugins : the list of plugins to enable. By default, plugins not on this list are left in their current state. With --strict , any currently enabled plugin not on this list will be disabled. disable_plugins : optional. Plugins to explicitly disable in the default sync without --strict . This is useful for documenting intentional exclusions when strict mode is not being used. bulk : controls whether plugin state changes are sent as a single batch API request or one at a time. Set to false for individual requests, which is slower but can be useful for diagnosing issues with specific plugins. Usage Run the script with no arguments to enable plugins in enable_plugins , disable plugins in disable_plugins , and leave everything else unchanged: python plugin_manager.py Before making any changes, the script saves a timestamped snapshot of the current plugin state. To restore the most recent snapshot: python plugin_manager.py --restore-snapshot To preview what would change without making any API calls: python plugin_manager.py --dry-run Common Use Cases Maintaining a known good plugin baseline Organizations that need to ensure consistent plugin configuration across upgrades, support sessions, or environment changes can maintain an enable_plugins list in the configuration file. Running the script applies the defined configuration to the instance. To also disable any currently enabled plugin not in the list: python plugin_manager.py --strict Testing on a stock Sisense instance Plugin interference is a common source of issues that are hard to reproduce. To quickly disable all plugins and test against a clean instance, clear enable_plugins in the configuration file and run with --strict . The current state is saved automatically before any changes are made, and can be restored when testing is complete: # with enable_plugins cleared in config.yaml python plugin_manager.py --strict # restore when done python plugin_manager.py --restore-snapshot Developing and testing a single plugin When many plugins are installed, Sisense rebuilds all of them whenever any plugin changes. Using --enable-one disables all currently enabled plugins and enables only the specified plugin, which can significantly reduce rebuild time on instances with many plugins: python plugin_manager.py --enable-one MyPlugin Snapshots The script saves snapshots as YAML files to a snapshots/ folder alongside the script. Snapshots capture the full enabled and disabled plugin state at a point in time. Snapshots are saved automatically before any run that changes state. Named snapshots can also be saved and restored explicitly: # save the current state with a specific name python plugin_manager.py --save-snapshot before-upgrade # list all saved snapshots python plugin_manager.py --list-snapshots # restore a specific snapshot python plugin_manager.py --restore-snapshot before-upgrade Including --log-version when saving a snapshot records the version of each plugin at that point in time, which can be useful for tracking plugin versions across upgrades. Full option reference usage: plugin_manager.py [-h] [--config FILE] [--dry-run] [--no-bulk] [--skip-snapshot] [--log-version] [--strict] [--enable-only | --disable-only] [--enable-one PLUGIN | --save-snapshot [NAME] | --list-snapshots | --restore-snapshot [NAME]] Manage Sisense plugin states via the REST API. options: -h, --help show this help message and exit --config FILE Path to config.yaml (default: config.yaml next to this script) --dry-run Preview what would change; make no API calls and save no snapshot --no-bulk Send one API call per plugin instead of a single batch PATCH. Slower but easier to diagnose partial failures. Overrides sisense.bulk in config. --skip-snapshot Do not automatically save a snapshot before this run --log-version Record each plugin's version field (from the API response) in any snapshot saved during this run (manual --save-snapshot or auto saved snapshot) --strict Also disable every currently enabled plugin that is not listed in enable_plugins. Without this flag, only plugins in disable_plugins are disabled; everything else is left alone. Always active for --restore- snapshot. --enable-only Run only the enable step; skip all disables --disable-only Run only the disable step; skip all enables --enable-one PLUGIN Enable exactly one plugin by name and disable all currently enabled others. Accepts the short or full plugin name. --save-snapshot [NAME] Save the current plugin state as a named snapshot. Omit NAME for an auto generated timestamp filename. Combine with --log-version to also record versions for each plugin. --list-snapshots List all saved snapshots with creation date and enabled plugin count --restore-snapshot [NAME] Restore plugin state to exactly match a saved snapshot. Omit NAME to use the most recently created snapshot. Always strict (disables plugins not in snapshot) unless --enable-only. Plugin names accept the short form (SwitchDimension) or the full form (plugin-SwitchDimension) in both CLI args and config.yaml. Default behavior: enable plugins in enable_plugins, disable plugins in disable_plugins, leave everything else unchanged. Use --strict to also disable any enabled plugin not in enable_plugins. A snapshot is saved automatically before each run that modifies state. Use --skip-snapshot to suppress this. Use Case Managing plugins via the REST API and a configuration file offers several advantages over manual administration: Applies plugin active list consistently and repeatably without manual steps Provides automatic rollback through snapshots saved before each change Supports quickly switching between plugin states for testing or development Reduces the time required to restore a known working plugin list after an issue is resolved Gives server administrators a record of plugin state changes over time (done via the tool and saved via snapshots) Summary With this tool in place, administrators and developers can manage Sisense plugin states quickly and reliably from the command line. The configuration file serves as a single source of truth for the intended plugin set, and the snapshot system ensures that any change can be reversed. Whether maintaining a consistent production environment, isolating issues related to plugins, or iterating on plugin development, the tool provides a practical and repeatable approach to plugin state management. The code is not compressed or obfuscated and can be modified as needed or used as a reference for similar tools. Full Python Code: """ Sisense Plugin Manager: manage Sisense plugin states via the REST API. Overview Manages Sisense plugin states via the REST API. Treats plugin configuration as code: define which plugins should be enabled in config.yaml, run the script, and the instance matches, quickly, from anywhere with network access. Before applying any changes, the current plugin state is automatically captured in a timestamped snapshot. If a change needs to be reverted, a single --restore-snapshot command rolls the instance back to exactly how it was. By default only the plugins explicitly listed in config.yaml are enabled or disabled, everything else is left as is. Use --strict to also disable any enabled plugin not in the list, making the instance enabled plugin list exactly match the config. Common use cases Maintain a known good plugin baseline Keep enable_plugins in config.yaml up to date. Run the script after upgrades, support sessions, or any time the instance may have drifted from the expected state. Test against a stock (no plugins) Sisense instance Eliminate plugins as a potential cause of a bug or issue. Clear enable_plugins in config.yaml, run --strict to disable everything, test, then restore when done, the snapshot is saved automatically before changes: # (clear enable_plugins in config.yaml) python plugin_manager.py --strict # ... reproduce the issue on a vanilla instance ... python plugin_manager.py --restore-snapshot # restores most recent Develop and iterate on a single plugin quickly With many plugins installed Sisense rebuilds all of them on each change. --enable-one isolates a single plugin and disables the rest, cutting rebuild time to a fraction on instances with many plugins: python plugin_manager.py --enable-one MyPlugin Roll back any change instantly Every run saves a timestamped snapshot of the state it found before making changes. Use --list-snapshots to find an earlier state and --restore-snapshot to return to it. Usage # Enable listed plugins, disable listed disable_plugins, leave rest unchanged python plugin_manager.py # Strict: also disables any enabled plugin not in enable_plugins python plugin_manager.py --strict # Preview what would change (no API calls, no auto snapshot) python plugin_manager.py --dry-run python plugin_manager.py --strict --dry-run # Run only one half of the sync python plugin_manager.py --enable-only # enable listed; skip all disables python plugin_manager.py --disable-only # disable listed; skip all enables # Enable exactly one plugin and disable every other currently enabled plugin python plugin_manager.py --enable-one SwitchDimension # Snapshots python plugin_manager.py --save-snapshot [NAME] # name is optional python plugin_manager.py --save-snapshot [NAME] --log-version # also record per-plugin versions python plugin_manager.py --list-snapshots python plugin_manager.py --restore-snapshot [NAME] # omit NAME → most recent # Other flags (combinable with any of the above) --no-bulk one API call per plugin instead of a single batch request --skip-snapshot do not save an auto-snapshot before this run --log-version record each plugin's version in any snapshot saved this run --config FILE use a config file other than the default config.yaml Plugin names All plugin names accept either the short form (SwitchDimension) or the full form (plugin-SwitchDimension), both in CLI arguments and in config.yaml. Matching is case insensitive. API token Admin → REST API → v1.0 → GET /authentication/tokens/api Setup pip install requests pyyaml # Python 3.7+ required cp config.yaml.example config.yaml # Edit config.yaml: set sisense.url, sisense.token, and enable_plugins list """ import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional import requests import yaml # Constants SCRIPT_DIR = Path(__file__).parent DEFAULT_CONFIG_PATH = SCRIPT_DIR / "config.yaml" SNAPSHOTS_DIR = SCRIPT_DIR / "snapshots" PLUGIN_PAGE_SIZE = 20 # plugins returned per page by GET /api/v1/plugins # Config helpers def load_config(config_path: Path) -> dict: """Load and return the YAML config file, printing an error and exiting if missing.""" if not config_path.exists(): print(f"Config file not found: {config_path}") print("Copy config.yaml.example to config.yaml and fill in your instance URL and API token.") sys.exit(1) with config_path.open() as file_handle: return yaml.safe_load(file_handle) def make_auth_headers(api_token: str) -> dict: """Return the Authorization + Content-Type headers required by the Sisense REST API.""" return { "Authorization": f"Bearer {api_token}", "Content-Type": "application/json", } # Plugin name normalization def normalize_plugin_name(raw_name: str) -> str: """ Return a canonical form: lowercase with any leading 'plugin-' prefix stripped. Both 'plugin-SwitchDimension' and 'SwitchDimension' normalize to 'switchdimension', so callers do not need to be consistent about the prefix. """ lowered = raw_name.lower() return lowered[len("plugin-"):] if lowered.startswith("plugin-") else lowered def build_name_lookup(plugin_names: list) -> set: """ Build a set that matches a plugin regardless of whether the caller used the 'plugin-' prefix. Each name contributes two entries: the bare normalized form and the 'plugin-<normalized>' form. Use with plugin_matches_lookup() to test whether an API plugin object is covered by a configured list. """ lookup: set = set() for name in plugin_names: normalized = normalize_plugin_name(name) lookup.add(normalized) lookup.add("plugin-" + normalized) return lookup def plugin_matches_lookup(plugin: dict, name_lookup: set) -> bool: """ Return True if either the plugin's folderName or its API name appears in name_lookup (case insensitive, prefix agnostic). """ plugin_identifiers = {plugin["folderName"].lower(), plugin["name"].lower()} return bool(plugin_identifiers & name_lookup) # Plugin list API def fetch_all_plugins(base_url: str, auth_headers: dict) -> list: """ Retrieve the full plugin list from the Sisense instance via paginated GET requests. Prints progress to stdout and exits on HTTP error. """ print("Fetching plugin list...") try: all_plugins = _paginate_plugins(base_url, auth_headers) except requests.HTTPError as http_error: print(f"Failed to fetch plugins: {http_error}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") return all_plugins def _paginate_plugins(base_url: str, auth_headers: dict) -> list: """Internal: collect every page from GET /api/v1/plugins.""" endpoint = f"{base_url}/api/v1/plugins" all_plugins: list = [] offset = 0 while True: response = requests.get( endpoint, headers=auth_headers, params={"limit": PLUGIN_PAGE_SIZE, "skip": offset}, timeout=30, ) response.raise_for_status() payload = response.json() page = payload.get("plugins", []) all_plugins.extend(page) offset += len(page) total_count = payload.get("count", 0) print(f" Fetched {offset} / {total_count} plugins...") if offset >= total_count or not page: break return all_plugins # Plugin versions def extract_plugin_versions(plugins: list) -> dict: """ Extract the version field from each plugin object in an API response. Returns a dict mapping folderName → version string for every plugin that carries a version field. Plugins with no version field are omitted so the caller can treat an absent key as 'version unknown'. """ versions: dict = {} for plugin in plugins: version_value = plugin.get("version") or plugin.get("pluginVersion") if version_value: versions[plugin["folderName"]] = str(version_value) return versions # Applying changes def _patch_plugins(base_url: str, auth_headers: dict, updates: list) -> None: """Send a PATCH /api/v1/plugins request. Raises requests.HTTPError on failure.""" response = requests.patch( f"{base_url}/api/v1/plugins", headers=auth_headers, json=updates, timeout=60, ) response.raise_for_status() def apply_plugin_updates( base_url: str, auth_headers: dict, updates: list, use_bulk: bool, ) -> tuple: """ Apply a list of enable/disable changes and print the outcome per plugin. use_bulk=True → single PATCH with all updates at once (faster, one round-trip). use_bulk=False → one PATCH per plugin (slower, easier to diagnose partial failures). Returns (success_count, error_count). """ if use_bulk: print(f"Sending bulk request ({len(updates)} plugin(s))...") try: _patch_plugins(base_url, auth_headers, updates) except requests.HTTPError as http_error: print(f" [ERROR] Bulk request failed: {http_error}") sys.exit(1) for update in updates: status_label = "ENABLED" if update["isEnabled"] else "DISABLED" print(f" [{status_label}] {update['folderName']}") return len(updates), 0 success_count = error_count = 0 for update in updates: folder_name = update["folderName"] status_label = "ENABLED" if update["isEnabled"] else "DISABLED" try: _patch_plugins(base_url, auth_headers, [update]) print(f" [{status_label}] {folder_name}") success_count += 1 except requests.HTTPError as http_error: print(f" [ERROR] {folder_name}: {http_error}") error_count += 1 return success_count, error_count # Snapshot I/O def save_snapshot( snapshot_name: str, enabled_folders: list, *, disabled_folders: Optional[list] = None, plugin_versions: Optional[dict] = None, ) -> Path: """ Write a snapshot YAML file to snapshots/<snapshot_name>.yaml. Fields written (in this order): enable_plugins: sorted list of currently enabled folderNames disabled_plugins: sorted list of currently-disabled folderNames (when provided) plugin_versions: {folderName: version} for each plugin (when provided) created: ISO-8601 UTC timestamp Key ordering is preserved (sort_keys=False) so the file reads naturally. """ SNAPSHOTS_DIR.mkdir(exist_ok=True) # created goes last so the plugin lists appear first in the file snapshot_data: dict = {"enable_plugins": sorted(enabled_folders)} if disabled_folders is not None: snapshot_data["disabled_plugins"] = sorted(disabled_folders) if plugin_versions: snapshot_data["plugin_versions"] = plugin_versions snapshot_data["created"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") snapshot_path = SNAPSHOTS_DIR / f"{snapshot_name}.yaml" with snapshot_path.open("w") as file_handle: yaml.dump(snapshot_data, file_handle, sort_keys=False, default_flow_style=False, allow_unicode=True) return snapshot_path def _snapshot_enable_list(snapshot: dict) -> list: """ Return the enabled plugin list from a snapshot, handling both the current 'enable_plugins' key and the legacy 'plugins' key used by older snapshots. """ return snapshot.get("enable_plugins") or snapshot.get("plugins", []) def load_snapshot(snapshot_name: str) -> dict: """Load a snapshot by stem name, exiting with an error if the file is missing.""" snapshot_path = SNAPSHOTS_DIR / f"{snapshot_name}.yaml" if not snapshot_path.exists(): print(f"Snapshot not found: {snapshot_path}") sys.exit(1) with snapshot_path.open() as file_handle: return yaml.safe_load(file_handle) def list_snapshot_files() -> list: """Return all snapshot YAML files in the snapshots directory, sorted by filename.""" if not SNAPSHOTS_DIR.exists(): return [] return sorted(SNAPSHOTS_DIR.glob("*.yaml")) def latest_snapshot_name() -> str: """ Return the stem of the snapshot with the most recent 'created' timestamp. Exits if no snapshots exist. """ snapshot_files = list_snapshot_files() if not snapshot_files: print("No snapshots found.") sys.exit(1) def created_timestamp(path: Path) -> str: with path.open() as file_handle: return yaml.safe_load(file_handle).get("created", "") return max(snapshot_files, key=created_timestamp).stem def auto_save_snapshot(plugins: list, include_version: bool) -> None: """ Save a snapshot from an already fetched plugin list, before applying changes. Accepts the plugin list fetched at the start of the run so no second API call is needed. Called before any changes are applied, giving every run that changes state a rollback point. Failures are caught and reported as a warning so a snapshot error never blocks the intended change. """ snapshot_name = datetime.now().strftime("auto_%Y%m%d_%H%M%S") print(f"\nSaving snapshot '{snapshot_name}' before applying changes...") try: enabled_folders = [p["folderName"] for p in plugins if p.get("isEnabled")] disabled_folders = [p["folderName"] for p in plugins if not p.get("isEnabled")] plugin_versions = extract_plugin_versions(plugins) if include_version else None snapshot_path = save_snapshot( snapshot_name, enabled_folders, disabled_folders=disabled_folders or None, plugin_versions=plugin_versions, ) print(f" Saved to {snapshot_path} ({len(enabled_folders)} enabled / {len(disabled_folders)} disabled)") except Exception as exc: print(f" Warning: auto snapshot failed: {exc}") # Sync computation def compute_plugin_updates( all_plugins: list, enable_names: list, disable_names: list, *, strict: bool, enable_only: bool, disable_only: bool, ) -> tuple: """ Determine which plugins need their state changed. Default behavior (strict=False): - Enable: plugins in enable_names that are currently disabled. - Disable: plugins in disable_names that are currently enabled. - Leave alone: everything else (not mentioned in either list). With strict=True: - Enable: same as default. - Disable: plugins in disable_names, PLUS any currently enabled plugin that does not appear in enable_names. This makes the instance exactly match enable_names. Direction overrides (applied after the above): enable_only=True → clears to_disable entirely. disable_only=True → clears to_enable entirely. Returns: to_enable: sorted folderNames to enable to_disable: sorted folderNames to disable unmatched_names: entries from enable_names that matched no instance plugin """ enable_lookup = build_name_lookup(enable_names) disable_lookup = build_name_lookup(disable_names) # Plugins from the instance that match the enable list matched_enable_plugins = [p for p in all_plugins if plugin_matches_lookup(p, enable_lookup)] enable_folder_set = {plugin["folderName"] for plugin in matched_enable_plugins} # Plugins that should be enabled but currently are not to_enable: list = [] if disable_only else sorted( plugin["folderName"] for plugin in matched_enable_plugins if not plugin.get("isEnabled") ) # Plugins that should be disabled but currently are enabled if enable_only: to_disable: list = [] elif strict: # Strict: disable every enabled plugin not in the enable list to_disable = sorted( plugin["folderName"] for plugin in all_plugins if plugin.get("isEnabled") and plugin["folderName"] not in enable_folder_set ) else: # Soft: only disable what is explicitly listed in disable_plugins to_disable = sorted( plugin["folderName"] for plugin in all_plugins if plugin.get("isEnabled") and plugin_matches_lookup(plugin, disable_lookup) ) # Entries from enable_names that found no matching plugin in the instance matched_normalized_names = ( {normalize_plugin_name(plugin["folderName"]) for plugin in matched_enable_plugins} | {normalize_plugin_name(plugin["name"]) for plugin in matched_enable_plugins} ) unmatched_names = sorted( name for name in enable_names if normalize_plugin_name(name) not in matched_normalized_names ) return to_enable, to_disable, unmatched_names def _build_update_payload(to_enable: list, to_disable: list) -> list: """Combine enable and disable lists into the PATCH payload format.""" return ( [{"folderName": folder_name, "isEnabled": True} for folder_name in to_enable] + [{"folderName": folder_name, "isEnabled": False} for folder_name in to_disable] ) def _execute_sync( base_url: str, auth_headers: dict, to_enable: list, to_disable: list, use_bulk: bool, ) -> int: """ Build the PATCH payload from to_enable / to_disable and apply it. Prints 'Nothing to change.' if both lists are empty. Returns the error count (0 on full success). """ updates = _build_update_payload(to_enable, to_disable) if not updates: print("Nothing to change.") return 0 _, error_count = apply_plugin_updates(base_url, auth_headers, updates, use_bulk) return error_count # Output helpers def _print_update_plan( to_enable: list, to_disable: list, already_correct_count: int, untouched_count: int = 0, ) -> None: """Print a summary of pending changes before they are applied.""" print(f" To enable: {len(to_enable)}") print(f" To disable: {len(to_disable)}") if already_correct_count > 0: print(f" Already set: {already_correct_count}") if untouched_count > 0: print(f" Untouched: {untouched_count} (not listed in config; left as-is)") print() def _print_dry_run_preview(to_enable: list, to_disable: list) -> None: """Print the would-be changes when --dry-run is active.""" if not to_enable and not to_disable: print("Nothing to change.") if to_enable: print("Would enable:") for folder_name in to_enable: print(f" + {folder_name}") if to_disable: print("Would disable:") for folder_name in to_disable: print(f" - {folder_name}") print("\n[DRY RUN] No changes made.") def _report_unmatched_names(unmatched_names: list) -> None: """Warn about configured plugin names that matched nothing in the instance.""" if unmatched_names: print(f"\nNot found in instance ({len(unmatched_names)}):") for name in unmatched_names: print(f" - {name}") # Commands def cmd_sync_to_config( base_url: str, auth_headers: dict, enable_names: list, disable_names: list, *, dry_run: bool, use_bulk: bool, strict: bool, enable_only: bool, disable_only: bool, skip_snapshot: bool, include_version: bool, ) -> None: """ Default command: sync the instance to match config.yaml. Enables plugins in enable_plugins; in strict mode, also disables any currently enabled plugin not in enable_plugins. Without strict, only plugins explicitly in disable_plugins are disabled. """ all_plugins = fetch_all_plugins(base_url, auth_headers) to_enable, to_disable, unmatched_names = compute_plugin_updates( all_plugins, enable_names, disable_names, strict=strict, enable_only=enable_only, disable_only=disable_only, ) enable_lookup = build_name_lookup(enable_names) disable_lookup = build_name_lookup(disable_names) matched_plugins = [p for p in all_plugins if plugin_matches_lookup(p, enable_lookup)] already_correctly_enabled = sum(1 for p in matched_plugins if p.get("isEnabled")) # Untouched: plugins in neither list, only relevant without --strict, where # we deliberately leave them alone untouched_count = 0 if strict or enable_only else sum( 1 for p in all_plugins if not plugin_matches_lookup(p, enable_lookup) and not plugin_matches_lookup(p, disable_lookup) ) # Table of plugins matched by the enable list if matched_plugins: print(f"Matched {len(matched_plugins)} plugin(s) from enable_plugins:\n") print(f" {'Folder name':<45} {'API name':<35} {'Enabled'}") print(f" {'-'*45} {'-'*35} {'-'*7}") for plugin in sorted(matched_plugins, key=lambda p: p["folderName"]): print(f" {plugin['folderName']:<45} {plugin['name']:<35} {plugin['isEnabled']}") print() _print_update_plan(to_enable, to_disable, already_correctly_enabled, untouched_count) if dry_run: _print_dry_run_preview(to_enable, to_disable) _report_unmatched_names(unmatched_names) return if not skip_snapshot and (to_enable or to_disable): auto_save_snapshot(all_plugins, include_version) error_count = _execute_sync(base_url, auth_headers, to_enable, to_disable, use_bulk) print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {error_count}") _report_unmatched_names(unmatched_names) def cmd_enable_single_plugin( base_url: str, auth_headers: dict, plugin_name: str, *, dry_run: bool, use_bulk: bool, skip_snapshot: bool, include_version: bool, ) -> None: """ Enable exactly one plugin and disable every other currently enabled plugin. The target plugin is identified by name (short or full form, case insensitive). This command does not consult enable_plugins or disable_plugins in config. """ target_name_lookup = build_name_lookup([plugin_name]) all_plugins = fetch_all_plugins(base_url, auth_headers) target_plugin = next( (plugin for plugin in all_plugins if plugin_matches_lookup(plugin, target_name_lookup)), None, ) if target_plugin is None: print(f"Plugin not found: {plugin_name!r}") print("Tip: check spelling against the Sisense admin panel, or run --save-snapshot to capture current names.") sys.exit(1) target_folder = target_plugin["folderName"] target_currently_on = target_plugin.get("isEnabled", False) to_enable = [] if target_currently_on else [target_folder] to_disable = sorted( plugin["folderName"] for plugin in all_plugins if plugin.get("isEnabled") and plugin["folderName"] != target_folder ) print(f"Target plugin: {target_folder}") print(f" Currently: {'enabled' if target_currently_on else 'disabled'}\n") _print_update_plan( to_enable, to_disable, already_correct_count=1 if target_currently_on else 0, ) if dry_run: _print_dry_run_preview(to_enable, to_disable) return if not skip_snapshot and (to_enable or to_disable): auto_save_snapshot(all_plugins, include_version) error_count = _execute_sync(base_url, auth_headers, to_enable, to_disable, use_bulk) print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {error_count}") def cmd_save_named_snapshot( base_url: str, auth_headers: dict, snapshot_name: str, include_version: bool, ) -> None: """Save the current plugin state as a named snapshot file.""" all_plugins = fetch_all_plugins(base_url, auth_headers) enabled_folders = [plugin["folderName"] for plugin in all_plugins if plugin.get("isEnabled")] disabled_folders = [plugin["folderName"] for plugin in all_plugins if not plugin.get("isEnabled")] plugin_versions = extract_plugin_versions(all_plugins) if include_version else None snapshot_path = save_snapshot( snapshot_name, enabled_folders, disabled_folders=disabled_folders or None, plugin_versions=plugin_versions, ) print(f"\nSnapshot '{snapshot_name}' saved to {snapshot_path}") print(f" {len(enabled_folders)} enabled / {len(disabled_folders)} disabled plugin(s) captured.") if plugin_versions is not None: print(f" Versions recorded for {len(plugin_versions)} plugin(s).") def cmd_list_snapshots() -> None: """Print a formatted table of all saved snapshots.""" snapshot_files = list_snapshot_files() if not snapshot_files: print("No snapshots found.") return print(f"Snapshots ({len(snapshot_files)}):\n") print(f" {'Name':<32} {'Created':<22} {'Enabled':<9} {'Versions'}") print(f" {'-'*32} {'-'*22} {'-'*9} {'-'*8}") for snapshot_path in snapshot_files: with snapshot_path.open() as file_handle: snapshot_data = yaml.safe_load(file_handle) enabled_count = len(_snapshot_enable_list(snapshot_data)) versions_logged = "yes" if snapshot_data.get("plugin_versions") else "no" print( f" {snapshot_path.stem:<32} " f"{snapshot_data.get('created', 'unknown'):<22} " f"{enabled_count:<9} " f"{versions_logged}" ) def cmd_restore_from_snapshot( base_url: str, auth_headers: dict, snapshot_name: str, *, dry_run: bool, use_bulk: bool, enable_only: bool, disable_only: bool, skip_snapshot: bool, include_version: bool, ) -> None: """ Restore plugin state to exactly match a saved snapshot. Restore is always strict: plugins in the snapshot are enabled and all other currently enabled plugins are disabled, matching the state at snapshot time. Use --enable-only to skip the disable step (only enable what the snapshot lists). """ snapshot = load_snapshot(snapshot_name) snapshot_enable_list = _snapshot_enable_list(snapshot) print(f"Snapshot '{snapshot_name}' ({snapshot.get('created', 'unknown')})") if snapshot.get("disabled_plugins"): print(f" {len(snapshot_enable_list)} enabled / {len(snapshot['disabled_plugins'])} disabled at snapshot time") else: print(f" {len(snapshot_enable_list)} plugin(s) enabled in snapshot") if snapshot.get("plugin_versions"): print(f" Plugin versions recorded: {len(snapshot['plugin_versions'])}") print() all_plugins = fetch_all_plugins(base_url, auth_headers) # Restore is inherently strict: disable anything not in the snapshot # unless the caller asked only to enable (enable_only=True). to_enable, to_disable, unmatched_names = compute_plugin_updates( all_plugins, enable_names=snapshot_enable_list, disable_names=[], strict=not enable_only, enable_only=enable_only, disable_only=disable_only, ) already_correct_count = max( 0, len(snapshot_enable_list) - len(to_enable) - len(unmatched_names) ) _print_update_plan(to_enable, to_disable, already_correct_count) if dry_run: _print_dry_run_preview(to_enable, to_disable) _report_unmatched_names(unmatched_names) return if not skip_snapshot and (to_enable or to_disable): auto_save_snapshot(all_plugins, include_version) error_count = _execute_sync(base_url, auth_headers, to_enable, to_disable, use_bulk) print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {error_count}") _report_unmatched_names(unmatched_names) # Main def main() -> None: import argparse parser = argparse.ArgumentParser( prog="plugin_manager.py", description="Manage Sisense plugin states via the REST API.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Plugin names accept the short form (SwitchDimension) or the full\n" "form (plugin-SwitchDimension) in both CLI args and config.yaml.\n" "\n" "Default behavior: enable plugins in enable_plugins, disable plugins\n" "in disable_plugins, leave everything else unchanged.\n" "Use --strict to also disable any enabled plugin not in enable_plugins.\n" "\n" "A snapshot is saved automatically before each run that\n" "modifies state. Use --skip-snapshot to suppress this." ), ) # Universal flags parser.add_argument( "--config", default=str(DEFAULT_CONFIG_PATH), metavar="FILE", help="Path to config.yaml (default: config.yaml next to this script)", ) parser.add_argument( "--dry-run", action="store_true", help="Preview what would change; make no API calls and save no snapshot", ) parser.add_argument( "--no-bulk", action="store_true", help=( "Send one API call per plugin instead of a single batch PATCH. " "Slower but easier to diagnose partial failures. " "Overrides sisense.bulk in config." ), ) parser.add_argument( "--skip-snapshot", action="store_true", help="Do not automatically save a snapshot before this run", ) parser.add_argument( "--log-version", action="store_true", help=( "Record each plugin's version field (from the API response) in any " "snapshot saved during this run (manual --save-snapshot or auto saved snapshot)" ), ) parser.add_argument( "--strict", action="store_true", help=( "Also disable every currently enabled plugin that is not listed in " "enable_plugins. Without this flag, only plugins in disable_plugins are " "disabled; everything else is left alone. " "Always active for --restore-snapshot." ), ) # Direction flags (mutually exclusive) direction_group = parser.add_mutually_exclusive_group() direction_group.add_argument( "--enable-only", action="store_true", help="Run only the enable step; skip all disables", ) direction_group.add_argument( "--disable-only", action="store_true", help="Run only the disable step; skip all enables", ) # Mode (mutually exclusive) mode_group = parser.add_mutually_exclusive_group() mode_group.add_argument( "--enable-one", metavar="PLUGIN", help=( "Enable exactly one plugin by name and disable all currently enabled " "others. Accepts the short or full plugin name." ), ) mode_group.add_argument( "--save-snapshot", metavar="NAME", nargs="?", const="__auto__", help=( "Save the current plugin state as a named snapshot. " "Omit NAME for an auto generated timestamp filename. " "Combine with --log-version to also record versions for each plugin." ), ) mode_group.add_argument( "--list-snapshots", action="store_true", help="List all saved snapshots with creation date and enabled plugin count", ) mode_group.add_argument( "--restore-snapshot", metavar="NAME", nargs="?", const="__latest__", help=( "Restore plugin state to exactly match a saved snapshot. " "Omit NAME to use the most recently created snapshot. " "Always strict (disables plugins not in snapshot) unless --enable-only." ), ) args = parser.parse_args() # --list-snapshots reads local files only; no config or API connection needed if args.list_snapshots: cmd_list_snapshots() return # Load config and connect config = load_config(Path(args.config)) sisense_config = config.get("sisense", {}) base_url = sisense_config.get("url", "").rstrip("/") api_token = sisense_config.get("token", "") if not base_url or not api_token: print("config.yaml must include sisense.url and sisense.token.") sys.exit(1) auth_headers = make_auth_headers(api_token) use_bulk = sisense_config.get("bulk", True) and not args.no_bulk # Support both 'enable_plugins' (current key) and 'plugins' (legacy key) enable_names = config.get("enable_plugins") or config.get("plugins", []) disable_names = config.get("disable_plugins", []) # Dispatch if args.enable_one: cmd_enable_single_plugin( base_url, auth_headers, args.enable_one, dry_run=args.dry_run, use_bulk=use_bulk, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) elif args.save_snapshot is not None: snapshot_name = ( datetime.now().strftime("snapshot_%Y%m%d_%H%M%S") if args.save_snapshot == "__auto__" else args.save_snapshot ) cmd_save_named_snapshot( base_url, auth_headers, snapshot_name, args.log_version, ) elif args.restore_snapshot is not None: snapshot_name = ( latest_snapshot_name() if args.restore_snapshot == "__latest__" else args.restore_snapshot ) cmd_restore_from_snapshot( base_url, auth_headers, snapshot_name, dry_run=args.dry_run, use_bulk=use_bulk, enable_only=args.enable_only, disable_only=args.disable_only, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) else: # Default: sync instance state to config if not enable_names and not disable_names: print("config.yaml must include at least one entry in 'enable_plugins'.") sys.exit(1) cmd_sync_to_config( base_url, auth_headers, enable_names, disable_names, dry_run=args.dry_run, use_bulk=use_bulk, strict=args.strict, enable_only=args.enable_only, disable_only=args.disable_only, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) if __name__ == "__main__": main()

      Jeremy Friedel
      Jeremy FriedelPosted 2 weeks ago • Last reply 2 weeks ago
      1
               
    • Blog banner
      • Widget & Dashboard ScriptsChevronRightIcon

      Programmatically Formatting Bar Chart Widget Value Labels in Sisense

                                                                                               

      Programmatically Formatting Bar Chart Widget Value Labels in Sisense This article outlines ways to programmatically format Sisense Bar Chart Widget Value labels via widget scripts , covering methods to prevent label overlap and apply consistent styling across all labels. Custom Styling for Data Labels The script below enables the formatting of Chart Widget Value labels by setting a custom background color, padding, and border-radius. Ensure the default data label UI option is disabled. Other CSS and Highcharts settings can be added as needed.         widget.on('render', function (se, ev) { ev.widget.queryResult.plotOptions.bar.dataLabels = { backgroundColor: '#f5d142', color: 'white', padding: 5, borderRadius: 5, enabled: true } })       Preventing Label Overlap The script below manually adjusts value label positioning to prevent overlap in densely populated bar chart widgets. The exact formulas for label positioning can be changed as needed.         widget.on('domready', function (se, ev) { var barWidth = $('.highcharts-series-group .highcharts-series rect', element).width(); $('.highcharts-data-labels .highcharts-label', element).each(function () { var labelWidth = $(this).find('rect').width(); var labelHeight = $(this).find('rect').height(); $(this).find('rect').attr('x', ($(this).find('rect').attr('x') + 2)); $(this).find('rect').attr('height', barWidth); $(this).find('rect').attr('y', ((labelHeight - barWidth) / 2)); }) })         Dynamically Increase Space for Labels If bar value labels overlap with the chart bars, you can dynamically adjust the maximum value on the y-axis to create additional space. A different formula, or a hard-coded value, can also be used as the y-axis maximum value.         widget.on('processresult', function (se, ev) { var maxValue = 0; var increasePercent = 0.2; ev.result.series.forEach(function (series) { series.data.forEach(function (dataItem) { if (dataItem.y > maxValue) maxValue = dataItem.y; }) ev.result.yAxis[0].max = maxValue + (increasePercent * maxValue); }) })       Conclusion These scripts enable customizing dynamically formatted and well-positioned data labels in your Sisense charts, enhancing readability and aesthetics beyond the default Sisense data bar data labels in bar chart widgets. For further discussion of these types of scripts, see the  Dynamically Formatted Data Labels article Example Of Custom Labels Added via Scripting   Y-Axis Maximum Set To a Very Large Value Check out this related content:  Academy Documentation

      Jeremy Friedel
      Jeremy FriedelPosted 1 year ago • Last reply 1 month ago
      1
               
    • Blog banner
      • APIsChevronRightIcon

      Connection Tool - Programmatically Remove Unused Datasource Connections, and List All Connections

                                                                                                                                                                       

      Connection Tool - A Tool to Programmatically Remove Unused Datasource Connections, and List All Connections     Managing connections within your Sisense server can become complex over time, if there are a large number of connections, and connections are often added, and replace earlier datasource connections. In some scenarios unused connections can accumulate, potentially cluttering the Connection Manager UI with no longer relevant connections. Although unused connections typically represent minimal direct security risk, it's considered best practice to maintain a clean, organized list of connections, and in some scenarios it can be desired to remove all unused connections. Sisense prevents the deletion of connections actively used in datasources, safeguarding your dashboards and datasources from disruptions. However, inactive or "orphaned" connections remain after datasources are deleted or a connection is replaced, potentially contributing to unnecessary UI complexity in the connection manager UI. Connections can be of any type Sisense supports, common types include various SQL connections, Excel files, and CSV files, as well as many data providers, such as Big Panda. This tool can also be used to list all connections, with no automatic deletion of unused connections. Introducing the Sisense Connection Prune Tool The Sisense Connection Prune Tool is a Python-based Sisense API based tool designed to programmatically identify and delete unused connections. It generates a CSV report listing all connections and their associated datasources, streamlining your connection management process. If desired, it can automatically remove all unused connections automatically from a Sisense server.   Using the Tool, sourcing the Virtual Environment, generating the Connection CSV   CSV Output of Used Connections and Associated Datasources   CSV opened visually to view as Table, Excel and other programs and text editors can open CSV files   Sisense Connection Prune Tool README Here's the full README included with the tool: # Sisense Connection Prune Tool A command-line tool to list used data connections and prune unused Sisense connections via CSV. It allows you to generate a CSV file of all connections and their dependencies, then delete those connections if needed, after removing the connections to keep from the CSV. ## Features - **Dry Run Mode**: Simulate deletions without making any changes. - **CSV-Based Flow**: Easily inspect and list connections to remove before deletion. - **Logging**: Extensive logs if needed. - **Error Handling**: Clear and descriptive messages for issues encountered during execution. ## Usage 1. **Activate the Virtual Environment** After downloading this project folder, activate the Python virtual environment bundled with it that includes all Python dependencies. - **Windows**: `venv\Scripts\activate` - **macOS/Linux**: `source venv/bin/activate` 2. **Configure the Tool** Open the `config.yaml` file and set your Sisense server URL, bearer token, CSV file path, and log file path. For example: ```yaml server_url: "https://your.sisense.server" bearer_token: "your_bearer_token_here" dry_run: true csv_file_path: "connections.csv" log_file_path: "connection_tool.log" ``` - **server_url**: The URL of your Sisense instance. - **bearer_token**: Your Sisense API token for authentication. - **dry_run**: If set to `true`, deletions will be simulated (no real deletions). - **csv_file_path**: Where the CSV file should be created and read from. - **log_file_path**: Where log file will be stored. 3. **Run the Tool** ```bash python3 ConnectionPruneTool.py ``` You will be prompted to choose an option: 1. **Generate connection CSV** - Fetches all Sisense connections. - Immediately removes (or simulates removing, if `dry_run` is `true`) any connection with no dependencies. - Writes all remaining connections and their dependencies to the CSV file. - **Important**: Inspect the CSV file and remove lines for any connections you want to **keep**. 2. **Delete connections from CSV list** - Reads the CSV file. - Removes or simulates removing each connection still listed. - Provides a summary report of which connections were deleted or bypassed. 4. **Review the Logs** Check the file specified in `log_file_path` for a record of all actions taken or simulated if needed. This is helpful for understanding what happened during each run and diagnosing any issues. ## Example Workflow 1. **Generate CSV** ```bash python3 ConnectionPruneTool.py # Choose option 1 when prompted ``` After generation, open the CSV file and **delete rows** corresponding to any connections you want to **keep**. 2. **Delete Connections** ```bash python3 ConnectionPruneTool.py # Choose option 2 when prompted ``` The tool will read the CSV and delete the remaining listed connections (or simulate deletion, if `dry_run` is enabled). ## Notes - Unused connections are removed automatically in step 1, without a CSV step - To keep a connection, remove its line from the CSV before proceeding with deletion. - If `dry_run` is set to `true`, no actual deletions will occur, only simulated logs and printed messages. - The log file will be cleared at the start of each run, so be sure to review or archive logs (or change log file name in config), if needed. ​ This is a command-line tool to list used data connections and prune unused Sisense connections, in general and via a CSV list. It allows a user with a data admin or higher bearer token to generate a CSV file of all connections and their dependencies, then delete those connections if needed, from the remaining connections in the CSV. Example Output: Deleted unused connection: Old_DB_Connection (ID: 123abc) CSV file generated at connections.csv. It contains 25 row(s) of active connections. Please review and remove lines for connections you want to keep before running deletion step by running tool again. Remaining lines will be deleted in deletion mode. Summary Report: Total lines in CSV (active used connections): 25 Deleted Unused Connections: - Old_DB_Connection No connections were bypassed.   API Endpoints Used Retrieve connections: GET /api/v2/connections Retrieve dependencies: GET /api/v2/connections/{connection_id}/getAllDependencies Delete connection: DELETE /api/v2/connections/{connection_id} Full Code connections.py - Uses Sisense API endpoints to: Fetch all connections (GET /api/v2/connections). Retrieve datasource dependencies for a specific connection (GET /api/v2/connections/{connection_id}/getAllDependencies). Delete a specific connection (DELETE /api/v2/connections/{connection_id}).     from helperFunctions import load_config, api_get, api_delete config = load_config() headers = {"Authorization": f"Bearer {config['bearer_token']}"} base_url = config["server_url"] # Retrieve all connections from Sisense def get_all_connections(): endpoint = f"{base_url}/api/v2/connections" return api_get(endpoint, headers) # Retrieve dependencies of a specific connection def get_connection_dependencies(connection_id): endpoint = f"{base_url}/api/v2/connections/{connection_id}/getAllDependencies" return api_get(endpoint, headers) # Delete a specific connection def delete_connection(connection_id): endpoint = f"{base_url}/api/v2/connections/{connection_id}" return api_delete(endpoint, headers) helperFunctions.py - Uses the Requests library to handle API requests (GET/DELETE). Catches and logs API errors. Uses PyYAML to read the config.yaml file for configuration.     import yaml import requests import logging # Load configuration from YAML file def load_config(): with open("config.yaml", "r") as file: return yaml.safe_load(file) # Configure logging settings def setup_logging(log_file): logging.basicConfig( filename=log_file, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", ) # Perform a GET request with error handling def api_get(endpoint, headers): try: response = requests.get(endpoint, headers=headers, verify=False) response.raise_for_status() return response.json() except requests.RequestException as e: logging.error(f"GET request failed: {e}") print(f"Error during GET request: {e}") return None # Perform a DELETE request with error handling def api_delete(endpoint, headers): try: response = requests.delete(endpoint, headers=headers, verify=False) response.raise_for_status() return response except requests.RequestException as e: logging.error(f"DELETE request failed: {e}") print(f"Error during DELETE request: {e}") return None ConnectionPruneTool.py - Serves as the entry point for the entire tool. Implements a CLI for user interaction. Invokes connections.py and helperFunctions.py to retrieve, analyze, and optionally delete Sisense connections. Uses Pandas to build a DataFrame of connection details and writes the results to a CSV file. Reads back the CSV file to remove selected connections after manual edits. Manages logs and prints summary reports to the terminal. import pandas as pd import logging import os from connections import ( get_all_connections, get_connection_dependencies, delete_connection, ) from helperFunctions import load_config, setup_logging # Load config yaml config = load_config() setup_logging(config["log_file_path"]) # Clear log file at start open(config["log_file_path"], "w").close() dry_run = config["dry_run"] csv_path = config["csv_file_path"] def generate_connections_csv(): """ Retrieves all connections from Sisense, deletes any that have no dependencies (unless in dry_run mode), and writes the remaining used connections and their dependent data models to a CSV file. """ # Create or clear existing CSV open(csv_path, "w").close() connections = get_all_connections() if connections is None: logging.error("Failed to retrieve connections.") print("Failed to retrieve connections.") return data = [] deleted = [] bypassed = [] # Go through each connection returned from the API for conn in connections: dependencies = get_connection_dependencies(conn["oid"]) if dependencies is None: logging.warning( f"Failed to retrieve dependencies for: {conn['name']} ({conn['oid']})" ) print( f"Failed to retrieve dependencies for: {conn['name']} ({conn['oid']})" ) bypassed.append(conn["name"]) continue # If no dependencies, optionally delete the connection (not in dry-run) if not dependencies: action_msg = "[Dry Run] Would delete" if dry_run else "Deleted" log_str = ( f"{action_msg} unused connection: {conn['name']} (ID: {conn['oid']})" ) print(log_str) logging.info(log_str) if not dry_run: try: response = delete_connection(conn["oid"]) # If response is None, treat it as a failed deletion if response is None: logging.error( f"Failed to delete unused connection: {conn['name']} ({conn['oid']})." ) print( f"Error deleting unused connection: {conn['name']} ({conn['oid']})" ) bypassed.append(conn["name"]) else: deleted.append(conn["name"]) except Exception as e: logging.error( f"Exception while deleting unused connection: {conn['name']} ({conn['oid']}) - {e}" ) print( f"Error deleting unused connection: {conn['name']} ({conn['oid']})" ) bypassed.append(conn["name"]) else: # If the connection is used, add each dependency row to CSV. # Use a fallback value if 'title' or 'oid' is missing. for dep in dependencies: dep_title = dep.get( "title", "NULL TITLE Share Datasource with associated Bearer Token user to include in CSV", ) dep_oid = dep.get("oid", "NULL OID") data.append( { "Connection Name": conn["name"], "Connection ID": conn["oid"], "Elasticube/Data Model Name": dep_title, "Elasticube/Data Model ID": dep_oid, } ) # Create a DataFrame of only the used connections (with datasource dependencies) df = pd.DataFrame(data) df.to_csv(csv_path, index=False) row_count = len(df) logging.info(f"Generated CSV at {csv_path}") print( f"CSV file generated at {csv_path}. " f"It contains {row_count} row(s) of used connections.\n" "Please review and remove lines for connections you want to keep " "before running deletion step by running tool again. Remaining lines will be deleted in deletion mode." ) # Summaries for auto-deleted (unused) connections print("\nSummary Report:") print(f"Total lines in CSV (active used connections): {row_count}") if deleted: print("\nDeleted Unused Connections:") for d in deleted: print(f" - {d}") else: print("\nNo connections were deleted in this step.") if bypassed: print("\nBypassed Connections:") for b in bypassed: print(f" - {b}") else: print("\nNo connections were bypassed in this step.") def delete_connections_from_csv(): """ Reads the CSV (remaining lines after user review, removing lines for connections to keep), then deletes each connection listed. If a delete fails or returns None, the connection is logged and added to 'bypassed'. """ if not os.path.exists(csv_path): print( "CSV file does not exist. Please generate it first or fix the config path." ) return df = pd.read_csv(csv_path) deleted = [] bypassed = [] for _, row in df.iterrows(): conn_id = row["Connection ID"] conn_name = row["Connection Name"] action_msg = "[Dry Run] Would delete" if dry_run else "Deleted" log_str = f"{action_msg} connection: {conn_name} ({conn_id})" print(log_str) logging.info(log_str) if not dry_run: try: response = delete_connection(conn_id) if response is None: logging.error( f"Failed to delete connection: {conn_name} ({conn_id})" ) print(f"Error deleting connection: {conn_name} ({conn_id})") bypassed.append(conn_name) else: deleted.append(conn_name) except Exception as e: logging.error( f"Exception while deleting connection: {conn_name} ({conn_id}) - {e}" ) print(f"Error deleting connection: {conn_name} ({conn_id})") bypassed.append(conn_name) # Print summary report print("\nSummary Report:") if deleted: print("Deleted Connections:") for d in deleted: print(f" - {d}") else: print("No connections were deleted.") if bypassed: print("\nBypassed:") for b in bypassed: print(f" - {b}") else: print("No connections were bypassed.") if __name__ == "__main__": # If there is no CSV file found, select step 1 automatically if not os.path.exists(csv_path): print( "No CSV file found; defaulting to generating connections CSV. " "Correct config if CSV file name has changed." ) generate_connections_csv() else: choice = input( "Choose an option:\n" "1 - Generate connection CSV\n" "2 - Delete connections from CSV list\n" "Enter your choice (1 or 2): " ) if choice == "1": generate_connections_csv() elif choice == "2": delete_connections_from_csv() else: print("Invalid option. Please enter '1' or '2'.") Conclusion By automating the detection of inactive connections and simplifying their removal, the Sisense Connection Prune Tool reduces clutter in the Sisense server Connection Manager UI while minimizing the risk of unintentionally impacting active datasources. Whether you opt for a dry-run mode to review potential deletions in a generated CSV file, or to simply list connections, or proceed with the full removal of unused connections, this tool offers a clear, flexible, and reliable approach to keeping your connections organized. A full copy of the tool, is attached below.        

      Jeremy Friedel
      Jeremy FriedelPosted 1 year ago • Last reply 3 months ago
      4
               
    • Blog banner
      • APIsChevronRightIcon

      UserReplaceTool - Automating Dashboard Ownership Transfers - Useful for Deleting User Accounts

                                                                                                       

      Automating Dashboard Ownership Transfer in Sisense with UserReplaceTool Managing and deleting user accounts in Sisense can create manual processes when users leave an organization or change roles. A frequent issue is the reassignment of dashboard ownership to prevent losing Sisense dashboards when a given user account is deleted, as deleting a Sisense user will delete all dashboards owned by that user. The UserReplaceTool addresses this task by automating the transfer of dashboard ownership of all dashboards owned by a given user, ensuring continuity and data integrity. Overview UserReplaceTool is a Python-based, API-based Tool solution designed to seamlessly transfer the ownership of dashboards and data models from one user to another in Sisense. This tool simplifies and automates this process, allowing organizations to reassign dashboard ownership without manual processes or the risk of losing dashboards and widgets. All components are accomplished by using Sisense API endpoint requests. Key Features Automated Dashboard Transfer : Reassigns ownership of all dashboards from the current user to a designated replacement user. Data Model Sharing : Ensures that all data models accessible and editable by the previous user are shared with the replacement user. Batch User Processing : Capable of handling multiple user transfers to a replacement user in a single operation, enhancing efficiency. Complete Logging:  All dashboards and datasource transferred are logged both in the console and in a separate log file. Setup Instructions 1. Setting Up the Environment To run the tool within a Python virtual environment , follow these steps: Activate the Python Virtual Environment : source /venv/bin/activate Create a Virtual Environment (if not already present): python3 -m venv .venv Install Dependencies : pip3 install -r requirements.txt For manual installation, including without using a Python virtual environment: pip3 install urllib3 jsonpath_ng pyyaml requests colorama 2. Configuration Edit the settings.yaml file to configure the tool. Key parameters include: Sisense Domain and Port : Specify the URL and port of your Sisense server, which is used for making API requests. API Bearer Token : Provide an admin-level bearer token for API authentication. See the  Sisense API Bearer Token Documentation for instructions on generating and using Bearer Tokens. Users to Replace : List the user IDs to be replaced. User IDs can be retrieved via the Users API or using the console command prism .user._id. Replacement User : Specify the user ID of the new replacement owner of all dashboards. This should typically be an admin level user. Data Model Sharing : Enable or disable the sharing of data models with the Replacement user (True or False), usually True. Dashboard Ownership Transfer : Enable or disable the transfer of dashboard ownership (True or False), usually True. Unlike Dashboards, if a user is deleted, the datasources themselves are not deleted from the server, ownership is automatically transferred to the main System Admin of the Sisense server.  3. Running the Tool Run the tool with the following command:   python3 replaceUser.py Practical Considerations Admin and Network Access : Ensure you have admin-level API access to the Sisense instance. Python Environment : Python3 and pip should be installed on the machine running the tool. All dependencies can be installed using Pip, and a Python virtual environment can be used. Once a Python Virtual Environment folder is set up it can be shared with the Tool and run directly on all systems using the same OS, but it is not cross OS compatible. By automating the transfer of dashboard ownership, UserReplaceTool provides a reliable and efficient solution for managing user transitions in Sisense. This ensures that datasources and dashboards remain accessible and under the control of the appropriate users, maintaining the continuity of Sisense resources. For further customization and configuration details, refer to the attached full Python Tool, which includes a README file and modify the "settings.yaml" file as necessary.    

      Jeremy Friedel
      Jeremy FriedelPosted 1 year ago • Last reply 3 months ago
      3
               
    • Blog banner
      • Add-ons & Plug-InsChevronRightIcon

      Customizing the Sisense User Interface with Interactive Buttons and Icons

                                                                                                               

      Customizing the Sisense User Interface with Interactive Buttons and Icons Sisense plugins  and scripts enable extensive customization of the Sisense user interface, allowing developers to add interactive elements such as buttons and icons to enhance functionality and user experience. A common use case of plugins involves adding clickable icons or buttons that trigger specific plugin features or open custom UI elements. This article outlines the process for adding these interactive elements using a practical example.   Icon Example Key Steps for Adding Clickable Buttons Follow this general flow to successfully add a custom clickable buttons or icon into the Sisense UI: Choose the UI placement:   Determine the exact area of the Sisense UI where the button or icon will appear. Identify the target parent container:   Find the appropriate parent element in the DOM that will contain the new button. Prevent duplication:   Implement checks to avoid adding duplicate buttons, if Sisense dashboard or prism event used fires more than once. Create the HTML button element:   Construct the button programmatically and apply necessary styling, adding either button text or icon image. Attach Click Listener:   Use JavaScript event listeners to define the button or icon interactive behavior. Practical Example: Adding a Button to the Filter Header Below is a clear and reusable example demonstrating the process of adding a clickable button to the filters header in a Sisense dashboard. This can easily be adapted for different parts of the dashboard or various plugin functionalities.   function addCustomButton() { // Step 1: Locate the UI container (in this example, the header to the right hand filter panel) const filtersContainer = document.querySelector('.filters-headline'); // Step 2: Avoid duplicate button addition if (filtersContainer && !filtersContainer.querySelector('.custom-btn')) { // Identify placement context, in this example next to the spacer element to the right of the filters label const spacerElement = filtersContainer.querySelector('.spacer'); if (spacerElement) { // Step 3: Create the button with appropriate classes const customButton = document.createElement('button'); customButton.classList.add('btn', 'btn--icon', 'btn--dark', 'btn--on-grey', 'custom-btn'); // Insert an SVG icon (example provided) customButton.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="100" height="100" viewBox="0,0,256,256"> <g fill="#5b6372" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"> <g transform="scale(8,8)"> <path d="M9,4c-1.64453,0 -3,1.35547 -3,3v18c0,1.64453 1.35547,3 3,3h17v-24zM9,6h3v11.41406l4,-4l4,4v-11.41406h4v16h-15c-0.35156,0 -0.68359,0.07422 -1,0.1875v-15.1875c0,-0.56641 0.43359,-1 1,-1zM14,6h4v6.58594l-2,-2l-2,2zM9,24h15v2h-15c-0.56641,0 -1,-0.43359 -1,-1c0,-0.56641 0.43359,-1 1,-1z"></path> </g> </g> </svg> `; = ` <!-- Your SVG icon here --> `; // Step 4: Define button functionality customButton.addEventListener('click', function () { // Replace with your plugin's custom action yourPlugin.action(); }); // Insert button into UI spacerElement.insertAdjacentElement('afterend', customButton); } } } // Add button upon dashboard load prism.on("dashboardloaded", function (e, args) { args.dashboard.on("widgetinitialized", addCustomButton); }); By following these principles and adapting the provided example, you can effectively enrich the Sisense interface, tailoring the UI to specific custom workflows and interactions.

      Jeremy Friedel
      Jeremy FriedelPosted 1 year ago
      0
               
    • Blog banner
      • Add-ons & Plug-InsChevronRightIcon

      Loading Amchart5 and Other External Libraries via Script Tags in Plugins

                                                                                                               

      Loading Amchart5 and Other External Libraries via Script Tags in Plugins   This article explains how to load external libraries, such as Amchart5 , into Sisense plugins , such as plugins that create new custom widget visualization types, by dynamically adding script tags to the page header to load the library. This method can avoid potential issues associated with other loading techniques but also offers flexibility such as using an external CDN to reduce plugin size and file count. Previous articles  have discussed how to load external libraries and modules for Sisense plugins via adding the file to the plugin folder, and adding the file to the "source" parameter array in the plugin.json.   What Is a Script Tag?   A   script tag   is an HTML element (<script>) used to embed or reference JavaScript code in an HTML document. When you include a script tag with a   src   attribute, the browser downloads and executes the external JavaScript file.   Why Use Script Tags for Loading External Libraries? For certain JavaScript libraries, especially visualization libraries like Amchart5, loading the library via a script tag can help avoid issues that might arise from bundling the files directly into the plugin. The script tag method provides several benefits: Flexibility:   It allows the option of using an external CDN. This can reduce the size of the plugin package and the number of files you need to manage. Auto-Updating:   When using a CDN, the external library can be updated automatically without modifying the plugin. Self-Hosting Option:   Alternatively, you can set the   src   parameter to a local path of JavaScript files uploaded within the plugin, ensuring that the plugin remains fully self-hosted and independent of any CDN. Loading External Libraries: Self-Hosted or Using a CDN Self-Hosted:   The script’s   src   is set to point to the files within the plugin folder. This follows this very specific format: /plugins/${name_of_plugin_folder}/{Any_subfolders_if_needed}/{full_name_of_the_file} This approach makes the plugin self-contained and avoids external dependencies. The src path must follow this format. CDN-Hosted:   The   src   parameter is set to the URL of the external CDN, such as: https://cdn.amcharts.com/lib/5/xy.js Using a CDN can reduce the plugin’s file size and benefit from auto-updating libraries, though it introduces a dependency on the CDN’s availability. Example Loader Script Below is an example of a loader file (loader.6.js) that dynamically adds script tags to the page header to load Amchart5 and its modules (AM5 is loaded in this example, as well as additional AM5 modules dealing with axises and animation, this is a self-hosted example and does not rely on a CDN). // List of script URLs to add to the page header const scriptUrls = [ "/plugins/am5Example/am5/index.js", "/plugins/am5Example/am5/xy.js", "/plugins/am5Example/am5/themes/Animation.js" ]; // Function to add a script tag to the header function addScriptToHeader(url) { const script = document.createElement("script"); script.src=url; // Optionally set async or defer attributes if needed script.async = false; document.head.appendChild(script); } // Loop through each URL and add it to the header scriptUrls.forEach(url => addScriptToHeader(url));   This can be modified to occur on a specific prism and dashboard event, as opposed to immediately when the plugin loads. In this script: document.createElement("script"):   Creates a new script tag. script.src:   Specifies the source of the JavaScript file. document.head.appendChild(script):   Adds the script tag to the Sisense page header. The   async   attribute is set to   false   to ensure that scripts load in the order they are added. Configuring plugin.json In your plugin’s   plugin.json, reference the loader file and the library file itself. The loader file then adds the necessary external scripts. An example plugin.json configuration is shown below: { "name": "am5Example", "pluginInfraVersion": 2, "isEnabled": true, "source": [ "am5/loader.6.js" ], "folderName": "am5Example", "version": "1.0.0" } This setup makes the external library (Amchart5, in this case) available to your plugin without bundling the entire library directly into the main codebase. Conclusion Using script tags to load external libraries like Amchart5 provides a flexible method for managing dependencies in Sisense plugins. Whether the libraries are self-hosted or rely on an external CDN, this method simplifies the management of external scripts and can lead to more efficient plugin development, and avoid issues with specific libraries such as Amchart5 that work best when loaded as a script element. The example plugin that demonstrates this type of loading is available for download below.    

      Jeremy Friedel
      Jeremy FriedelPosted 1 year ago • Last reply 1 year ago
      1
               
    • Blog banner
      • Widget & Dashboard ScriptsChevronRightIcon

      Passing Filters via URL Parameters for Dashboards with Separate Datasources

                                                                                       

      Passing Filters via URL Parameters for Dashboards with Separate Datasources   Sisense includes a native included feature and format for passing URL filters via URL parameters, as  documented here . By default, this functionality copies filters in full, including the datasource parameter of the filter, and includes every filter automatically. It results in very long URL's, and includes many parameters that are not always required, as the full filter object is included. Previous Knowledge Base articles articles have discussed how similar behavior can be recreated customized via scripting for more flexible usage and shorter URL's. However, those approaches applied only to member-type filters, excluding other filter types and multi-level dependent filters. The code shared below demonstrates a more flexible filter modification via URL modification approach. It includes both creating URL parameters and reading URL parameters for filter modification, whether this code is in a script or plugin. This method applies to all filter types and can be used to transfer filters between dashboards using different datasources. This code works in both dashboard and widget scripts as well as Sisense Add-ons/Plugins . If your datasources use different dimension names, this code can be adopted to map and match the aligned dimensions. This code uses the full filter JAQL parameter object, if any filter parameters outside the JAQL parameter are needed they can be added via slight modification of this code. This code below includes code for: Reading URL Parameters: The decodeAllFilterParams function updates dashboard filters by reading URL parameters, using keys such as FilterParam and FilterMLParam concatenated with a sanitized version of the filter’s dimension. This is then used to modify the dashboard's filters. Writing URL Parameters: The redirectToDashboard function encodes all current filters back into the URL when a redirect occurs. This ensures that filters persist across dashboard changes even when the underlying datasource differs. Managing the Redirect Flag: Functions like redirectFlagPresent and removeRedirectFlag check for and remove a special flag in the URL (i.e., "justRedirected"). This flag prevents infinite redirect loops. These functions are only required if your code includes redirect functionality.     /** * Returns a URL-encoded version of the given string. * (This removes special characters and spaces that do not belong in a URL.) */ function sanitizeDim(str) { return encodeURIComponent(str || ""); } /** * Reads URL parameters to update existing dashboard filters. * - For single-level filters, the key is "FilterParam" + sanitized(filter.jaql.dim). * - For multi-level filters, the key is "FilterMLParam" + sanitized(level.dim). * Updates only filters whose dimension already exists on the dashboard. */ function decodeAllFilterParams() { if (!dashboard.filters || !dashboard.filters.$$items) return; const url = new URL(window.location.href); dashboard.filters.$$items.forEach(filter => { if (filter.jaql) { const key = "FilterParam" + sanitizeDim(filter.jaql.dim); if (url.searchParams.has(key)) { const value = url.searchParams.get(key); try { const parsed = JSON.parse(value); console.log("Updated filter.jaql for dimension", filter.jaql.dim, "with", parsed); filter.jaql = parsed; } catch (err) { console.log("Error parsing parameter for key", key, err); } } } if (filter.levels && Array.isArray(filter.levels)) { filter.levels.forEach(level => { const key = "FilterMLParam" + sanitizeDim(level.dim); if (url.searchParams.has(key)) { const value = url.searchParams.get(key); try { const parsed = JSON.parse(value); console.log("Updated multi-level filter for dimension", level.dim, "with", parsed); level.filter = parsed; } catch (err) { console.log("Error parsing multi-level parameter for key", key, err); } } }); } }); } /** * Checks if the redirect flag ("justRedirected") is present in the URL. */ function redirectFlagPresent() { const currentUrl = new URL(window.location.href); return currentUrl.searchParams.get("justRedirected") === "true"; } /** * Redirects to a different dashboard by updating the URL. * It re-encodes all current filters into the URL so that the receiving dashboard can read them. * - Single-level filters: Key = "FilterParam" + sanitized(filter.jaql.dim) * - Multi-level filters: Key = "FilterMLParam" + sanitized(level.dim) * Additionally, a redirect flag ("justRedirected") is added to prevent infinite loops. */ function redirectToDashboard(newDashboardId) { if (redirectFlagPresent()) { console.log("Redirect flag is present; skipping additional redirect."); return; } const currentDashboardId = dashboard.oid; if (!currentDashboardId) { console.log("No current dashboard ID; cannot redirect."); return; } const currentUrl = window.location.href; if (!currentUrl.includes(currentDashboardId)) { console.log("Current URL does not contain the dashboard ID; skipping redirect."); return; } const replacedUrl = currentUrl.replace(currentDashboardId, newDashboardId); const urlObj = new URL(replacedUrl); // Re-add all current filters. if (dashboard.filters && dashboard.filters.$$items) { dashboard.filters.$$items.forEach(filter => { if (filter.jaql) { const key = "FilterParam" + sanitizeDim(filter.jaql.dim); urlObj.searchParams.set(key, JSON.stringify(filter.jaql)); } if (filter.levels && Array.isArray(filter.levels)) { filter.levels.forEach(level => { const key = "FilterMLParam" + sanitizeDim(level.dim); if (level.filter) { urlObj.searchParams.set(key, JSON.stringify(level.filter)); } else { urlObj.searchParams.set(key, JSON.stringify(level)); } }); } }); } // Add the redirect flag. urlObj.searchParams.set("justRedirected", "true"); const finalUrl = urlObj.toString(); console.log("Redirecting to URL:", finalUrl); window.location.href = finalUrl; } /** * Removes the redirect flag ("justRedirected") from the URL using history.replaceState, * allowing future filter changes to trigger a redirect. */ function removeRedirectFlag() { const url = new URL(window.location.href); if (url.searchParams.has("justRedirected")) { url.searchParams.delete("justRedirected"); window.history.replaceState(null, "", url.toString()); console.log("Removed redirect flag from URL."); } } An example of a URL including multiple filters encoded in this matter: ?FilterParam%255BMainTable.email%255D=%7B"table":"MainTable","column":"email","dim":"%5BMainTable.email%5D","datatype":"text","merged":true,"datasource":%7B"address":"LocalHost","title":"Old%20Data%20Script%20Test","id":"localhost_aOldIAAaDataIAAaScriptIAAaTest","database":"aOldIAAaDataIAAaScriptIAAaTest","fullname":"localhost%2FOld%20Data%20Script%20Test","live":false%7D,"firstday":"mon","locale":"en-us","title":"email","collapsed":true,"isDashboardFilter":true,"filter":%7B"explicit":true,"multiSelection":true,"members":%5B"aabramofaz@amazon.com"%5D%7D%7D&FilterMLParam%255BMainTable.first_name%255D=%7B"explicit":true,"multiSelection":true,"members":%5B"Abeu","Abigale"%5D%7D&FilterMLParam%255BMainTable.gender%255D=%7B"explicit":false,"multiSelection":true,"all":true%7D&FilterParam%255BMainTable.Revenue%2520Date%2520(Calendar)%255D=%7B"table":"MainTable","column":"Revenue%20Date","dim":"%5BMainTable.Revenue%20Date%20(Calendar)%5D","datatype":"datetime","merged":true,"datasource":%7B"address":"LocalHost","title":"New%20Data%20Script%20Test","id":"localhost_aNewIAAaDataIAAaScriptIAAaTest","database":"aNewIAAaDataIAAaScriptIAAaTest","fullname":"localhost%2FNew%20Data%20Script%20Test","live":false%7D,"firstday":"mon","locale":"en-us","level":"days","title":"Days%20in%20Revenue%20Date","collapsed":true,"isDashboardFilter":true,"filter":%7B"explicit":true,"multiSelection":true,"members":%5B"2024-12-11T00:00:00","2025-03-12T00:00:00"%5D%7D%7D&justRedirected=true This includes the full filter information for the filters shown in the screenshot below:   This code above shows a flexible approach to passing and modifying filters via URL parameters in Sisense. By leveraging custom scripting, you can transfer filters, regardless of their type, between dashboards even if they are backed by separate datasources. This level of control is particularly useful for scenarios where dashboards need to transfer filters seamlessly, preserving filter context during navigation. In practice, you can adapt this code to your specific needs, including mapping dimensions when datasources differ or integrating it within dashboard or widget scripts and plugins. This can be used to enhance user experience by maintaining filter continuity, but it also can allow the implementation of advanced routing logic and customize dashboard interactions.  Whether you are transferring filters between a legacy dashboard and a new dashboard or building dynamic filter-preserving navigation for a custom applications, this solution provides a good starting code for many types of customizations.

      Jeremy Friedel
      Jeremy FriedelPosted 1 year ago
      0
               
    • Blog banner
      • Widget & Dashboard ScriptsChevronRightIcon

      Redirect users to different dashboards based on dashboard filters

                                                                                                                               

      Redirect users to different dashboards based on dashboard filters This article  discusses and shares the full code of a  dashboard script  that redirects users to a different dashboard ID based on the user's  filter selections or initial loaded filter state. In the particular example shared in this article, the script checks whether the selected date filter (either from a members filter or a filter date range) includes an earlier date than the earliest date in the current dashboard's data source. If this is the case, the script redirects the user to a specified alternate dashboard, preserving any additional URL segments and query parameters in the URL. Any other type of filter state can also be used to determine when the script should redirect, including non-date filters using similar scripts. Example Use Case Limited vs. Full Dataset : One dashboard data source might serve only recent data, while another dashboard data source stores all historical data. If a user selects a date that pre-dates one dataset, this script will seamlessly redirect them to the alternate dashboard that includes older data. Improved User Experience : Instead of displaying empty or invalid data for older dates, users are smoothly guided to the correct “all data” view dashboard. Full Script     /** * Main function that checks a single-level date filter of day-level granularity * (either 'members' or 'from/to') for a single selected by variable date dimension. The dashboard script determines * whether the selected date is earlier than the earliest date in the current dashboard’s * datasource. If so, the function redirects to another dashboard while preserving the rest * of the URL parameters and path segments. * * Use Case: * - One dashboard has a datasource with only recent data (no older dates). * - Another dashboard has a datasource with all historical data. * - If a user selects a date earlier than the “recent” dataset supports, the script redirects * them to the “all data” dashboard, retaining any query parameters or path info in the URL. */ function checkAndRedirectIfNeeded() { /** * Toggle console logging for clarity or debugging. */ const enableLogging = true; /** * The dimension string for the date filter in the current dashboard's datasource. * Example: "[Commerce.Date (Calendar)]". * This code checks a single-level date filter of day-level granularity for this dimension. Change as needed */ const dateDim = "[Commerce.Date (Calendar)]"; /** * The alternate dashboard ID to which a user is redirected if they select a date * older than what the current datasource includes. This dashboard ID replaces the current * dashboard ID in the URL, preserving any extra path or query parameters. */ const alternateDashboardId = "67bebb6863199f002a7b0906"; /** * Logs to the console only if enableLogging is true. * @param {...any} msgs - Items to log. */ function log(...msgs) { if (enableLogging) { console.log(...msgs); } } /** * Searches the dashboard's filters for a single-level date filter matching dimStr. * Returns the filter object if found, otherwise null. * @param {string} dimStr - The date dimension to look for. */ function findDateFilter(dimStr) { if (!dashboard.filters || !dashboard.filters.$$items) { log("No filters accessible on this dashboard."); return null; } for (const filter of dashboard.filters.$$items) { if (filter.jaql && filter.jaql.dim === dimStr) { log("Found date filter for dimension:", dimStr); return filter; } } return null; } /** * Builds a minimal JAQL query to fetch the earliest date (sorted ascending) from the specified dimension. * Uses the current dashboard's datasource, and returns only 1 row (count: 1). * * This ensures we get the earliest available date in the dataset for the dimension in question, * typically returned as an ISO-like string in the response data. * * @param {string} dimStr - The dimension string representing the date field. */ function buildEarliestDateQuery(dimStr) { return { datasource: dashboard.datasource, metadata: [ { jaql: { dim: dimStr, datatype: "datetime", level: "days", sort: "asc" } } ], count: 1 }; } /** * Runs the JAQL query using Sisense's internal HTTP service if available, otherwise jQuery.ajax. * The request is made synchronous (async: false). If the internal service doesn't exist, fallback is standard AJAX. * * @param {Object} jaql - The JAQL query object. * @returns {Promise} - Resolves with the server response or rejects on error. */ function runHTTP(jaql) { const $internalHttp = prism.$injector.has("base.factories.internalHttp") ? prism.$injector.get("base.factories.internalHttp") : null; const ajaxConfig = { url: `/api/datasources/${encodeURIComponent(jaql.datasource.title)}/jaql`, method: "POST", data: JSON.stringify(jaql), contentType: "application/json", dataType: "json", async: false, xhrFields: { withCredentials: true } }; return $internalHttp ? $internalHttp(ajaxConfig, false) : $.ajax(ajaxConfig); } /** * Replaces only the dashboard ID in the current URL with alternateDashboardId, * preserving the rest of the URL (query parameters, path segments, etc.). * If the current dashboard ID is not found, the redirect is canceled. */ function redirectToAlternateDashboard() { const currentDashId = dashboard.oid; if (!currentDashId) { log("Cannot determine current dashboard ID. Redirect canceled."); return; } const currentUrl = window.location.href; if (!currentUrl.includes(currentDashId)) { log("Current URL does not contain the expected dashboard ID. Redirect canceled."); return; } const newUrl = currentUrl.replace(currentDashId, alternateDashboardId); log("Redirecting to alternate dashboard:", newUrl); // Perform the actual redirect window.location.href = newUrl; } /** * Main logic flow: * 1) Fetch the earliest date in the dataset for dateDim by building and running a JAQL query. * 2) Locate the single-level date filter for dateDim in the dashboard filters. * 3) Check the user's selected date: * - If 'members' type, use the first member. * - If 'from/to' type, use 'from'. * 4) Compare the selected date (JS Date) to the earliest date (JS Date). * 5) If selected date is earlier, redirect to the alternate dashboard; otherwise, do nothing. */ const jaqlQuery = buildEarliestDateQuery(dateDim); runHTTP(jaqlQuery) .then(response => { if (!response?.data?.values?.length) { log("No data returned for earliest date query. No redirect needed."); return; } // The earliest date is stored in .data, typically an ISO-like date string (e.g. "2023-01-05T00:00:00"). const earliestDateString = response.data.values[0][0].data; log("Earliest date in the dataset:", earliestDateString); // Convert the earliest date string to a JS Date object for comparison const earliestDate = new Date(earliestDateString); if (isNaN(earliestDate.valueOf())) { log("Earliest date is invalid or unrecognized:", earliestDateString); return; } // Find the single-level date filter on the dashboard const dateFilter = findDateFilter(dateDim); if (!dateFilter || !dateFilter.jaql.filter) { log("Date filter not found or missing filter object. Skipping redirect check."); return; } let selectedDateStr = null; // If it's a 'members' type filter, use the first member, this is the earliest if ( Array.isArray(dateFilter.jaql.filter.members) && dateFilter.jaql.filter.members.length > 0 ) { selectedDateStr = dateFilter.jaql.filter.members[0]; log("Filter type: 'members'. Selected date string:", selectedDateStr); // If it has 'from' (and possibly 'to'), use 'from' } else if (dateFilter.jaql.filter.from) { selectedDateStr = dateFilter.jaql.filter.from; log("Filter type: 'from/to'. Selected 'from' date string:", selectedDateStr); } else { log("Filter is not recognized or has no selected date. Skipping redirect."); return; } // Convert the filter's date string to a JS Date const selectedDate = new Date(selectedDateStr); if (isNaN(selectedDate.valueOf())) { log("Selected date is invalid or unrecognized:", selectedDateStr); return; } // Compare for earliest date if (selectedDate < earliestDate) { log("Selected date is earlier than the earliest dataset date. Redirecting..."); redirectToAlternateDashboard(); } else { log("Selected date is within or after earliest dataset date. No redirect needed."); } }) .catch(error => { log("Error retrieving earliest date or comparing filter date:", error); }); } /** * Calls the main logic function after the dashboard is initialized. */ dashboard.on("initialized", function () { checkAndRedirectIfNeeded(); }); /** * Also calls the main logic function whenever filters change. */ dashboard.on("filterschanged", function () { checkAndRedirectIfNeeded(); });     How It Works Earliest Date Lookup The script first builds a small custom secondary JAQL query (sorted in ascending order, limited to one result) to determine the earliest date in the data source. It executes this query using Sisense’s internal service function, using the native Sisense cookies. This type of scripting functionality has been discussed in more detail in previous knowledge base articles, such as this article . Filter Inspection After fetching the earliest date, the script searches for a single-level date filter matching the configured date dimension. If the filter uses a   members   array, the script reads the first member. If the filter uses a   from and to range   structure, it uses the   from   date, as this is always the earliest. Comparison The script converts both the earliest date and the selected date into JavaScript   Date   objects. If the selected date is earlier than the earliest date, the script triggers a redirect. Redirect Logic Only the dashboard ID portion of the current URL is replaced with the specified alternateDashboardId. Path segments, query parameters, and any other pre-existing parts of the URL remain the same. Use Cases Limited vs. Full Dataset When one Sisense dashboard is restricted to recent data (for example, the last 30 days) and another includes all historical data. This script diverts users to the appropriate dashboard seamlessly. Consistent User Experience Avoids confusion when a date filter extends beyond a limited dataset’s timeframe. Instead of showing empty or invalid data, the user is sent to a more comprehensive dashboard. Multi-Dashboard Navigation Ensures the user can continue with the same URL parameters and path segments, simply swapping the restricted dashboard for the alternate “all data” one. Conclusion By employing this or similar scripts based on this form of customization (this example is more advanced in using a custom secondary JAQL request to fetch the first data value, but this is not necessary in many scenarios), it is possible to streamline date-based filter navigation or other filter-based navigation between dashboards or data sources, guaranteeing for example that if a date selection falls outside the scope of a limited dataset, the user automatically sees data in a broader data source dashboard. This functionality improves user experience and allows for wide customizability.  Example of a date selection including dates before the first date in dataset [A LT Text: A digital interface showing a table titled "Days in Date." Below the title, there are three dates listed: 11/26/20, 11/25/20, and 11/27/20. The date 11/26/20 is highlighted in yellow, along with a toggle switch on the right side of the image.] Example Output with logging variable enabled [ ALT Text: A screenshot of a computer terminal showing error messages related to date filters for a calendar dimension in a dataset. The messages indicate that a selected date of November 26, 2020, is valid while a date of November 25, 2020, is earlier than the earliest date in the dataset (November 11, 2020). The terminal displays a redirecting notice to an alternate dashboard.]  

      Jeremy Friedel
      Jeremy FriedelPosted 1 year ago
      0
               
    • Blog banner
      • Add-ons & Plug-InsChevronRightIcon

      Plugin - RemoveImageDownload - Removing Items From Sisense Menus

                                                                                                               

      Plugin – RemoveImageDownload – Removing Items From Sisense Menus This article discusses a  plugin (and an equivalent dashboard script ) that removes the “Download as Image” option from Sisense menus. This same approach can be applied to remove any other menu option in Sisense by adjusting the relevant code. Organizations may want to hide or remove specific menu items for several reasons: Security : Prevent certain menu options from being used. Enforcing Best Practices:  Remove menu items not used in the standard recommended workflow Streamlined UI : Hide unused menu items to simplify the user experience. Plugin Overview RemoveImageDownload  plugin removes the “Download Image” option from all standard Sisense menus which include the: Dashboard Toolbar Menu Widget Context Menu (in a dashboard) Direct Download Menu in the Widget Editor and Viewer   This is accomplished using the Sisense beforemenu event . That event runs before any standard Sisense menu is rendered, giving scripts and plugins an opportunity to modify or remove items. The primary JavaScript file of the plugin (main.6.js) can also be used as a standalone dashboard script, simply copy and paste the code as a dashboard script. The code works by listening to the beforemenu event, which Sisense triggers before rendering any of its menus. Within that event, the script functions filters the args.settings.items array, each “item” in this array may itself contain nested sub-items (like the “Download” submenu). The script locates the specific item or items to remove by checking parameters such as item.caption or item.command.title, and then filters the menu items out accordingly. This code design is flexible, by adjusting which string is matched in the filter logic this code can be used for any standard Sisense menu item.   Below is the complete code, with explanatory comments:             // // Remove the image option from the download menu in the widget context (dashboard view) // prism.on("beforemenu", (ev, args) => { if ( !args.settings?.name || !args.settings?.scope?.widget || !args.settings.name.includes("widget") || !args.settings.items ) { return; } const downloadMenu = args.settings.items.find(item => item.caption === "Download"); if (!downloadMenu?.items) { return; } const downloadWithoutImage = downloadMenu.items.filter(item => { return !(item.command && item.command.title === "dashboard.widget.commands.image.title"); }); if (downloadWithoutImage !== undefined) { downloadMenu.items = downloadWithoutImage; } }); // // Remove the "Download Image" option from the main dashboard Download menu // prism.on("beforemenu", (ev, args) => { if ( !args.settings?.name || !args.settings.name.includes("dashboard") || args.settings.name.includes("widget") || !args.settings.items ) { return; } const downloadMenu = args.settings.items.find(item => item.caption === "Download"); if (!downloadMenu?.items) { return; } const downloadWithoutImage = downloadMenu.items.filter(item => { return !(item.command && item.command.title === "Download Image"); }); if (downloadWithoutImage !== undefined) { downloadMenu.items = downloadWithoutImage; } }); // // Remove the image download option from the Widget Editor UI download dropdown // prism.on("beforemenu", (ev, args) => { if (!args.settings?.items) { return; } args.settings.items = args.settings.items.filter(item => { return !(item.command && item.command.title === "dashboard.widget.commands.image.title"); }); });               Plugin Readme Below is the README for the RemoveImageDownload plugin.               # Remove Image Download from Menu ## Description This plugin removes the option to download images from the widget and dashboard menu UI. It does not modify Sisense API endpoints, including the image API endpoint. ## Installation 1. **Download the plugin:** - Extract the compressed archive to /opt/sisense/storage/plugins/ - Or, in Admin > System Management > File Management, upload the extracted folder to the plugins directory. 2. **Wait for the plugin to load.** 3. **Refresh the page.**             Screenshots Below are before-and-after screenshots of the relevant Sisense menus when the plugin is enabled. With Plugin   Without Plugin   With Plugin Without Plugin   With Plugin Without Plugin Further Customization and Other Use Cases To use this code to hide additional items or different item adjust the strings checked in item.command.title or item.caption to hide or rename other menu items. Console logging the args.settings.items array temporarily can be used to find the appropriate title and caption strings.The conditionals can be modified as needed to only apply the function to a particular menu. The same beforemenu event can be used to modify Sisense menus as needed in Sisense scripts and plugins.  The plugin is downloadable below.   How did the plugin work for you? What other type of plugin are you looking to learn more about? Let me know in the comments!

      Jeremy Friedel
      Jeremy FriedelPosted 1 year ago
      0