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
    Use Cases
    • Blog banner
      • PySisenseChevronRightIcon

      Managing Sisense Plugin States with PySisense

                                                                                                       

      Managing Sisense Plugin States with PySisense Overview Enabling and disabling Sisense plugins through the admin interface becomes time consuming when maintaining consistent plugin configurations across environments, testing against a clean instance, or iterating on plugin development. This article describes plugin_manager_pysisense.py , a Python command-line tool that manages Sisense plugin states using the PySisense library and a configuration file. The script is also a working example of how PySisense can be used for Sisense automation. This tool covers the same functionality as plugin_manager.py , documented in Managing Sisense Plugin States via the REST API . The difference is that this version delegates all API request code to PySisense rather than using the requests library directly. The two scripts share the same config.yaml format and produce identical output. The script enables and disables plugins, isolates a single plugin for faster development builds, and saves and restores named snapshots of plugin state. It does not install, uninstall, or update plugins, and does not roll back plugin versions. About PySisense PySisense is introduced in PySisense: Programmable Sisense Environment Management on the Sisense Community as a Python SDK for the Sisense REST API. It provides wrappers around common Sisense operations, including plugin management, dashboard access, and user administration. It is installed via pip and does not need to be placed alongside the script. This script provides a practical example of PySisense applied to a real automation task. Using PySisense rather than writing directly against the REST API reduces the amount of code required and removes several categories of boilerplate. Authentication headers, HTTP session management, SSL configuration, pagination across large result sets, and request logging are all handled by the library. The consistent SisenseClient and module pattern also makes it straightforward to extend a script to cover other Sisense operations, such as users, dashboards, or data models, without reworking connection and authentication code. Scripts built on PySisense are shorter and easier to read than equivalent scripts built on requests directly, and the library is maintained and tested independently of the scripts that use it. Note: The current release of PySisense on PyPI does not yet include the Plugins module used in this script. This functionality is planned for the next PySisense release. The development branch of the PySisense repository already contains it and can be installed directly from there. This script uses the Plugins class from PySisense: get_all_plugins() returns the full plugin list as a list of dicts, handling the pagination in the underlying API automatically. enable_plugins(names, bulk=True) enables one or more plugins by name. When bulk=True , all changes are sent in a single PATCH request. disable_plugins(names, bulk=True) disables one or more plugins by name, with the same bulk option. A SisenseClient instance is constructed from the config values and passed to the Plugins class. All HTTP calls are then handled by PySisense. from pysisense import Plugins, SisenseClient client = SisenseClient(domain="https://your-sisense-instance.com", token="YOUR_TOKEN") plugins = Plugins(api_client=client) # List all installed plugins all_plugins = plugins.get_all_plugins() # Enable plugins by short name, full name, or folderName result = plugins.enable_plugins(["SwitchDimension", "plugin-CustomPlugin"], bulk=True) # Disable plugins result = plugins.disable_plugins(["UnneededPlugin"], bulk=True) Both enable_plugins and disable_plugins return a summary dict with changed , already_enabled or already_disabled , not_found , and errors keys. Features that require knowing the current plugin state before making changes, such as strict mode, enable-one, and dry-run previews, call get_all_plugins() once at the start of the command and compute the set of changes at the script level. Plugin name matching Plugin names in the configuration file and on the command line accept either the short form ( SwitchDimension ) or the full form ( plugin-SwitchDimension ). Matching is case-insensitive. PySisense normalizes names internally, so both forms work with enable_plugins() and disable_plugins() as well. Prerequisites Python 3.10 or later Network access to the Sisense instance (the tool runs remotely and does not need to be installed on the server) A Sisense API token: Admin > REST API > v1.0 > GET /authentication/tokens/api Setup Install the required Python packages: pip install pysisense 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 . 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_pysisense.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_pysisense.py --restore-snapshot To preview what would change without making any API calls: python plugin_manager_pysisense.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_pysisense.py --strict Testing on a stock Sisense instance Plugin interference is a common source of issues that are difficult 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_pysisense.py --strict # restore when done python plugin_manager_pysisense.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_pysisense.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_pysisense.py --save-snapshot before-upgrade # list all saved snapshots python plugin_manager_pysisense.py --list-snapshots # restore a specific snapshot python plugin_manager_pysisense.py --restore-snapshot before-upgrade Including --log-version when saving a snapshot records the version of each plugin at that point in time. Snapshots saved by plugin_manager_pysisense.py and plugin_manager.py are compatible. Both scripts read from the same snapshots/ directory, and each handles the other's snapshot format. Full option reference usage: plugin_manager_pysisense.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 PySisense. 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 --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. --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 PySisense 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 Demonstrates how PySisense can be used to build automation scripts for Sisense instances 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. The script also serves as a concrete example of how PySisense can be used in automation scripts: constructing a SisenseClient from config values, passing it to a feature class, and using the returned result dicts to drive output and error handling. The code is not compressed or obfuscated and can be modified as needed or used as a reference for similar tools. j""" plugin_manager_pysisense.py — Manage Sisense plugin states via pySisense. Same feature set as plugin_manager.py but delegates all API calls to the pySisense library instead of calling the Sisense REST API directly. Configuration is read from config.yaml. This script is also a working example of how pySisense can be used for Sisense automation. Install pySisense with: pip install pysisense Overview Manages Sisense plugin states via pySisense. 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 Clear enable_plugins in config.yaml, run --strict to disable everything, test, then restore: python plugin_manager_pysisense.py --strict python plugin_manager_pysisense.py --restore-snapshot Develop and iterate on a single plugin quickly python plugin_manager_pysisense.py --enable-one MyPlugin Roll back any change instantly python plugin_manager_pysisense.py --list-snapshots python plugin_manager_pysisense.py --restore-snapshot before-upgrade Usage # Enable listed plugins, disable listed disable_plugins, leave rest unchanged python plugin_manager_pysisense.py # Strict: also disables any enabled plugin not in enable_plugins python plugin_manager_pysisense.py --strict # Preview what would change (no API calls, no auto snapshot) python plugin_manager_pysisense.py --dry-run python plugin_manager_pysisense.py --strict --dry-run # Run only one half of the sync python plugin_manager_pysisense.py --enable-only # enable listed; skip all disables python plugin_manager_pysisense.py --disable-only # disable listed; skip all enables # Enable exactly one plugin and disable every other currently enabled plugin python plugin_manager_pysisense.py --enable-one SwitchDimension # Snapshots python plugin_manager_pysisense.py --save-snapshot [NAME] python plugin_manager_pysisense.py --save-snapshot [NAME] --log-version python plugin_manager_pysisense.py --list-snapshots python plugin_manager_pysisense.py --restore-snapshot [NAME] # omit NAME for 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 pysisense pyyaml # Python 3.10+ 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 import yaml from pysisense import Plugins, SisenseClient SCRIPT_DIR = Path(__file__).parent DEFAULT_CONFIG = SCRIPT_DIR / "config.yaml" SNAPSHOTS_DIR = SCRIPT_DIR / "snapshots" # Config def load_config(path: Path) -> dict: if not path.exists(): print(f"Config file not found: {path}") print("Copy config.yaml.example to config.yaml and fill in your instance URL and API token.") sys.exit(1) with path.open() as f: return yaml.safe_load(f) def build_client(sisense_cfg: dict) -> SisenseClient: url = sisense_cfg.get("url", "") token = sisense_cfg.get("token", "") if not url or not token: print("config.yaml must include sisense.url and sisense.token.") sys.exit(1) is_ssl = not url.startswith("http://") return SisenseClient(domain=url, token=token, is_ssl=is_ssl) # Name matching — mirrors pySisense internals, used for dry-run previews and # strict-mode computation (pySisense handles matching internally for real calls) def _normalize(name: str) -> str: lower = name.lower() return lower[len("plugin-"):] if lower.startswith("plugin-") else lower def _build_lookup(names: list) -> set: lookup: set = set() for n in names: norm = _normalize(n) lookup.add(norm) lookup.add("plugin-" + norm) return lookup def _matches_lookup(plugin: dict, lookup: set) -> bool: return bool({plugin["folderName"].lower(), plugin["name"].lower()} & lookup) # Snapshot file I/O def write_snapshot_file(name: str, snapshot: dict) -> Path: SNAPSHOTS_DIR.mkdir(exist_ok=True) path = SNAPSHOTS_DIR / f"{name}.yaml" with path.open("w") as f: yaml.dump(snapshot, f, sort_keys=False, default_flow_style=False, allow_unicode=True) return path def read_snapshot_file(name: str) -> dict: path = SNAPSHOTS_DIR / f"{name}.yaml" if not path.exists(): print(f"Snapshot not found: {path}") sys.exit(1) with path.open() as f: data = yaml.safe_load(f) # Translate 'enable_plugins' key (plugin_manager.py snapshot format) to 'plugins' if "enable_plugins" in data and "plugins" not in data: data["plugins"] = data["enable_plugins"] return data def list_snapshot_files() -> list: if not SNAPSHOTS_DIR.exists(): return [] return sorted(SNAPSHOTS_DIR.glob("*.yaml")) def latest_snapshot_name() -> str: files = list_snapshot_files() if not files: print("No snapshots found.") sys.exit(1) def created_at(path: Path) -> str: with path.open() as f: return yaml.safe_load(f).get("created", "") return max(files, key=created_at).stem def _auto_save_snapshot(all_plugins: list, include_version: bool) -> None: name = datetime.now().strftime("auto_%Y%m%d_%H%M%S") print(f"\nSaving snapshot '{name}' before applying changes...") try: enabled = sorted(p["folderName"] for p in all_plugins if p.get("isEnabled")) disabled = sorted(p["folderName"] for p in all_plugins if not p.get("isEnabled")) snapshot: dict = { "plugins": enabled, "created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), } if include_version: versions: dict = {} for p in all_plugins: v = p.get("version") or p.get("pluginVersion") if v: versions[p["folderName"]] = str(v) if versions: snapshot["plugin_versions"] = versions path = write_snapshot_file(name, snapshot) print(f" Saved to {path} ({len(enabled)} enabled / {len(disabled)} disabled)") except Exception as exc: print(f" Warning: auto snapshot failed: {exc}") # Sync computation — determines which plugins need their state changed. # Used for dry-run previews, strict mode, and enable-one. def _compute_updates( all_plugins: list, enable_names: list, disable_names: list, *, strict: bool, enable_only: bool, disable_only: bool, ) -> tuple: enable_lookup = _build_lookup(enable_names) disable_lookup = _build_lookup(disable_names) matched_enable = [p for p in all_plugins if _matches_lookup(p, enable_lookup)] enable_folder_set = {p["folderName"] for p in matched_enable} to_enable: list = [] if disable_only else sorted( p["folderName"] for p in matched_enable if not p.get("isEnabled") ) if enable_only: to_disable: list = [] elif strict: to_disable = sorted( p["folderName"] for p in all_plugins if p.get("isEnabled") and p["folderName"] not in enable_folder_set ) else: to_disable = sorted( p["folderName"] for p in all_plugins if p.get("isEnabled") and _matches_lookup(p, disable_lookup) ) matched_norms = ( {_normalize(p["folderName"]) for p in matched_enable} | {_normalize(p["name"]) for p in matched_enable} ) unmatched = sorted(n for n in enable_names if _normalize(n) not in matched_norms) return to_enable, to_disable, unmatched # Output helpers def _print_update_plan( to_enable: list, to_disable: list, already_correct: int, untouched: int = 0, ) -> None: print(f" To enable: {len(to_enable)}") print(f" To disable: {len(to_disable)}") if already_correct > 0: print(f" Already set: {already_correct}") if untouched > 0: print(f" Untouched: {untouched} (not listed in config; left as-is)") print() def _print_dry_run_preview(to_enable: list, to_disable: list) -> None: if not to_enable and not to_disable: print("Nothing to change.") if to_enable: print("Would enable:") for f in to_enable: print(f" + {f}") if to_disable: print("Would disable:") for f in to_disable: print(f" - {f}") print("\n[DRY RUN] No changes made.") def _report_unmatched(unmatched: list) -> None: if unmatched: print(f"\nNot found in instance ({len(unmatched)}):") for n in unmatched: print(f" - {n}") def _apply_enable(plugins_obj: Plugins, to_enable: list, bulk: bool) -> int: if not to_enable: return 0 result = plugins_obj.enable_plugins(to_enable, bulk=bulk) if "error" in result: print(f"Failed to enable plugins: {result['error']}") sys.exit(1) for f in result["changed"]: print(f" [ENABLED] {f}") return len(result.get("errors", [])) def _apply_disable(plugins_obj: Plugins, to_disable: list, bulk: bool) -> int: if not to_disable: return 0 result = plugins_obj.disable_plugins(to_disable, bulk=bulk) if "error" in result: print(f"Failed to disable plugins: {result['error']}") sys.exit(1) for f in result["changed"]: print(f" [DISABLED] {f}") return len(result.get("errors", [])) # Commands def cmd_sync_to_config( plugins_obj: Plugins, enable_names: list, disable_names: list, *, dry_run: bool, bulk: bool, strict: bool, enable_only: bool, disable_only: bool, skip_snapshot: bool, include_version: bool, ) -> None: print("Fetching plugin list...") all_plugins = plugins_obj.get_all_plugins() if all_plugins and "error" in all_plugins[0]: print(f"Failed to fetch plugins: {all_plugins[0]['error']}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") to_enable, to_disable, unmatched = _compute_updates( all_plugins, enable_names, disable_names, strict=strict, enable_only=enable_only, disable_only=disable_only, ) enable_lookup = _build_lookup(enable_names) disable_lookup = _build_lookup(disable_names) matched = [p for p in all_plugins if _matches_lookup(p, enable_lookup)] already_correct = sum(1 for p in matched if p.get("isEnabled")) untouched = 0 if strict or enable_only else sum( 1 for p in all_plugins if not _matches_lookup(p, enable_lookup) and not _matches_lookup(p, disable_lookup) ) if matched: print(f"Matched {len(matched)} plugin(s) from enable_plugins:\n") print(f" {'Folder name':<45} {'API name':<35} {'Enabled'}") print(f" {'-'*45} {'-'*35} {'-'*7}") for p in sorted(matched, key=lambda x: x["folderName"]): print(f" {p['folderName']:<45} {p['name']:<35} {p['isEnabled']}") print() _print_update_plan(to_enable, to_disable, already_correct, untouched) if dry_run: _print_dry_run_preview(to_enable, to_disable) _report_unmatched(unmatched) return if not skip_snapshot and (to_enable or to_disable): _auto_save_snapshot(all_plugins, include_version) errors = _apply_disable(plugins_obj, to_disable, bulk) + _apply_enable(plugins_obj, to_enable, bulk) if not to_enable and not to_disable: print("Nothing to change.") else: print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {errors}") _report_unmatched(unmatched) def cmd_enable_one( plugins_obj: Plugins, plugin_name: str, *, dry_run: bool, bulk: bool, skip_snapshot: bool, include_version: bool, ) -> None: target_lookup = _build_lookup([plugin_name]) print("Fetching plugin list...") all_plugins = plugins_obj.get_all_plugins() if all_plugins and "error" in all_plugins[0]: print(f"Failed to fetch plugins: {all_plugins[0]['error']}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") target = next((p for p in all_plugins if _matches_lookup(p, target_lookup)), None) if target 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["folderName"] target_on = target.get("isEnabled", False) to_enable = [] if target_on else [target_folder] to_disable = sorted( p["folderName"] for p in all_plugins if p.get("isEnabled") and p["folderName"] != target_folder ) print(f"Target plugin: {target_folder}") print(f" Currently: {'enabled' if target_on else 'disabled'}\n") _print_update_plan(to_enable, to_disable, already_correct=1 if target_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) errors = _apply_disable(plugins_obj, to_disable, bulk) + _apply_enable(plugins_obj, to_enable, bulk) if not to_enable and not to_disable: print("Nothing to change.") else: print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {errors}") def cmd_save_named_snapshot(plugins_obj: Plugins, name: str, include_version: bool) -> None: print("Fetching plugin list...") all_plugins = plugins_obj.get_all_plugins() if all_plugins and "error" in all_plugins[0]: print(f"Failed to fetch plugins: {all_plugins[0]['error']}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") enabled = sorted(p["folderName"] for p in all_plugins if p.get("isEnabled")) disabled = sorted(p["folderName"] for p in all_plugins if not p.get("isEnabled")) snapshot: dict = { "plugins": enabled, "created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), } if include_version: versions: dict = {} for p in all_plugins: v = p.get("version") or p.get("pluginVersion") if v: versions[p["folderName"]] = str(v) if versions: snapshot["plugin_versions"] = versions path = write_snapshot_file(name, snapshot) print(f"Snapshot '{name}' saved to {path}") print(f" {len(enabled)} enabled / {len(disabled)} disabled plugin(s) captured.") if include_version and snapshot.get("plugin_versions"): print(f" Versions recorded for {len(snapshot['plugin_versions'])} plugin(s).") def cmd_list_snapshots() -> None: files = list_snapshot_files() if not files: print("No snapshots found.") return print(f"Snapshots ({len(files)}):\n") print(f" {'Name':<32} {'Created':<22} {'Enabled':<9} {'Versions'}") print(f" {'-'*32} {'-'*22} {'-'*9} {'-'*8}") for path in files: with path.open() as f: data = yaml.safe_load(f) enabled_count = len(data.get("plugins") or data.get("enable_plugins", [])) versions_logged = "yes" if data.get("plugin_versions") else "no" print( f" {path.stem:<32} " f"{data.get('created', 'unknown'):<22} " f"{enabled_count:<9} " f"{versions_logged}" ) def cmd_restore_snapshot( plugins_obj: Plugins, name: str, *, dry_run: bool, bulk: bool, enable_only: bool, disable_only: bool, skip_snapshot: bool, include_version: bool, ) -> None: snapshot = read_snapshot_file(name) snapshot_plugins = set(snapshot.get("plugins", [])) print(f"Snapshot '{name}' ({snapshot.get('created', 'unknown')})") print(f" {len(snapshot_plugins)} plugin(s) enabled in snapshot") if snapshot.get("plugin_versions"): print(f" Plugin versions recorded: {len(snapshot['plugin_versions'])}") print() print("Fetching plugin list...") all_plugins = plugins_obj.get_all_plugins() if all_plugins and "error" in all_plugins[0]: print(f"Failed to fetch plugins: {all_plugins[0]['error']}") sys.exit(1) print(f" {len(all_plugins)} plugins total\n") by_folder = {p["folderName"]: p for p in all_plugins} to_enable: list = [] if disable_only else sorted( f for f in snapshot_plugins if f in by_folder and not by_folder[f].get("isEnabled") ) to_disable: list = [] if enable_only else sorted( p["folderName"] for p in all_plugins if p.get("isEnabled") and p["folderName"] not in snapshot_plugins ) not_in_instance = sorted(f for f in snapshot_plugins if f not in by_folder) already_correct = max(0, len(snapshot_plugins) - len(to_enable) - len(not_in_instance)) _print_update_plan(to_enable, to_disable, already_correct) if dry_run: _print_dry_run_preview(to_enable, to_disable) if not_in_instance: print(f"\nNot found in instance ({len(not_in_instance)}):") for f in not_in_instance: print(f" - {f}") return if not skip_snapshot and (to_enable or to_disable): _auto_save_snapshot(all_plugins, include_version) errors = _apply_disable(plugins_obj, to_disable, bulk) + _apply_enable(plugins_obj, to_enable, bulk) if not to_enable and not to_disable: print("Nothing to change.") else: print(f"\nDone. Enabled: {len(to_enable)} Disabled: {len(to_disable)} Errors: {errors}") if not_in_instance: print(f"\nNot found in instance ({len(not_in_instance)}):") for f in not_in_instance: print(f" - {f}") # Main def main() -> None: import argparse parser = argparse.ArgumentParser( prog="plugin_manager_pysisense.py", description="Manage Sisense plugin states via pySisense.", 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." ), ) parser.add_argument( "--config", default=str(DEFAULT_CONFIG), 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_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_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() if args.list_snapshots: cmd_list_snapshots() return cfg = load_config(Path(args.config)) sisense_cfg = cfg.get("sisense", {}) bulk = sisense_cfg.get("bulk", True) and not args.no_bulk client = build_client(sisense_cfg) plugins_obj = Plugins(api_client=client) if args.enable_one: cmd_enable_one( plugins_obj, args.enable_one, dry_run=args.dry_run, bulk=bulk, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) elif args.save_snapshot is not None: name = ( datetime.now().strftime("snapshot_%Y%m%d_%H%M%S") if args.save_snapshot == "__auto__" else args.save_snapshot ) cmd_save_named_snapshot(plugins_obj, name, args.log_version) elif args.restore_snapshot is not None: name = ( latest_snapshot_name() if args.restore_snapshot == "__latest__" else args.restore_snapshot ) cmd_restore_snapshot( plugins_obj, name, dry_run=args.dry_run, bulk=bulk, enable_only=args.enable_only, disable_only=args.disable_only, skip_snapshot=args.skip_snapshot, include_version=args.log_version, ) else: enable_names = cfg.get("enable_plugins") or cfg.get("plugins", []) disable_names = cfg.get("disable_plugins", []) 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( plugins_obj, enable_names, disable_names, dry_run=args.dry_run, bulk=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() 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 • Last reply 1 week ago
      1
               
      • How-Tos & FAQsChevronRightIcon

      LAG / LEAD Functions

                               

      Question LAG & LEAD Functions are functions that give access to multiple rows within a table, without the need for a self-join. The LAG function is used to access data from a previous row, while the LEAD function is used to return data from rows further down the result set. A  perfect example of these functions in use is with the “Sales Order” scenario. A department store is trying to monitor the purchase trends of its customers; more specifically, per customer, what’s the average time it took to repeat an order? For us to do so, we must take a record in the table (Order Date) and compare it to another record in the same table. What are some ways we can do that? In our example, we will analyze & compare the efficiency between the multiple solutions. We will start by addressing the problem in the most common/traditional of solving a problem like this. Answer   Steps: 1. Create A New ElastiCube Add Data > Microsoft SQL Server > Connect To Server Pull in Sales Order Header Table "How are we going to manipulate that table to get it into a format that we would want to work with?" Uncheck fields that you don’t need; looking for high-level/important data   BUILD! 2. Let's Create A Custom SQL Expression   What we are getting here a single customer’s activity.  It also orders the Order Date in ascending order (from first date to last) "For this specific customer, I want to be able to see __ amount of days between each order. How can we compute / compare those dates? In order to do so, we want to take the order table and join it to itself, where the customer is the same and the order date is offset to the previous or future date." So, let’s start by leveraging the rank function to assign an order number to every date… The Rank function is important here because within the Customer ID, we want to rank based off the order date, group it per customer, and order by the order date. This basically creates create a system that makes it so that it is comparing itself to itself minus 1. This is what sets up our ranking per order. In order for us to join the table to itself, let’s create a sub query and inside of it, put the entire Customer’s table: (Type Of Join:) What it’s getting joined on: Result: 3. Now That We Have The Order Date & The Total Due, We Can Start Subtracting Them Off Each Other & Start Doing Differences Now you would want to go back to the top; the first “Select” of the sub query and change the claims to: Preview: Now that we saw that this function is working, we can now delete our customer filter, order by filter. Now we get all the differences across all of the customers. Issue With This Solution The main issue with this solution is that it requires a lot of processing due to the two tables. Although we are really using one table, we still have two and the same data is repeated between them. This extra storage isn’t necessary, creates the long processing, and can also create confusion for the customer. Solution 2: Doing A Lookup Function On Itself (Own Data Set). To improve upon the last solution, is there a way to pull in one table and do the manipulation in Sisense, without creating a second table? Answer: Yes! What we can do is take the custom table and add it as a view to the source; that way we only import one table Steps: 1. Duplicate "SalesOrderHeader 1", Rename "SalesOrderHeader 2" BUILD! For us to do a look up on this table, we need to have need to have one key to join.  The key is a combination of the customer ID + ranking, that’s what makes it unique. 2. So First, Create A New Field, Record_Rank (Int),  Input: BUILD Add Data > Custom SQL Table   3. Head Back Into SalesOrderHeader 2, And Set Up The Different Fields To Create The Key + New Field > CurrCustomerRankKey (Text) Do Entire Build 4. Let's Create A Test Function + Custom SQL Expression > Test Function   5. Go Back To SalesOrderHeaders 2 Table, Do Lookup In There + New Field > PrevOrderDate (Date-Time) + New Field > PrevTotalDue (Float) + New Field > DaysBetweenOrders(Int) + New Field > DiffOrderAmount Schema Build: Solution 3: LAG & LEAD Functions This solution does not include a self-join Steps: 1. Open Up SQL Server (TC SQL) Go to same database (Adventure Works 2012) > create new query Let’s start off the new query as: Now we want to dynamically compare one against the other. Instead of doing a self-join, it tells itself to look at the look at the record/number that is above or below and just pull that value in. So it’s almost like a lookup , but it doesn’t require a join , it just requires you to tell it how to sort the data and just dynamically go up and go down and pull the field that you want in to the current record. This is where the LAG & LEAD functions step in To go back a record, we call the LAG function. Within the LAG function, we must define the field that we want to pull in (order date) and how many records back do you want to go (1). Next we want to group by (partition by) and Order By a type of variety and then order it either ASC / DES. To make our “look up” a bit more distinct we can Result: If we want to compare up against future dates, we would use: And its result would be: What we can also pull in the Total Due and calculate the previous Total Due: Which would require a sub query: To get of the first null, we would enter: Pull in everything from t2.* and now we can calculate our differences  (Date Diff, etc): As for getting this entire query into Sisense, we want to create a View

      Community_Admin
      Community_AdminPosted 4 years ago • Last reply 1 week ago
      1
               
    • 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
               
      • Help and How-ToChevronRightIcon
      Your Sisense charts are clean, but are your tooltips telling the whole story?
                                                                                               
      Mia Isaacson
      Mia IsaacsonPosted 2 weeks ago
               
      0
               
    • Blog banner
      • News & UpdatesChevronRightIcon

      Choosing the Right Replication Strategy: Evaluating ADF + CDC vs. Snowflake Openflow

                                                       

      If you're delivering embedded analytics through Sisense, your dashboard is only as good as the data behind it. But how often do you think about what's happening upstream in the pipelines and ingestion architectures that determine whether that data is fresh, reliable, and cost-efficient before it ever reaches a visualization? QBeeQ is known in the Sisense ecosystem as a plugin and integration provider, but that's only part of what we do. This post is a window into the broader, strategic side of our work: architectural decision-making that happens well upstream of the BI layer but directly impacts the analytics experience your customers see. The client here uses Sisense to deliver embedded dashboards. We built their Snowflake-based data platform from the ground up:  integrations, ELT, data modeling, and governance with Sisense sitting at the top of the stack. The next question became: is our data replication approach from MS SQL Server into Snowflake the right one long-term, or should we move to Snowflake Openflow? A Platform Already in Motion Our relationship with our client didn't start with this POC. We previously helped them design and build a Snowflake-based data platform from the ground up, including data integrations, ELT processes, data models, and governance. That work gave them a solid, modern foundation for their analytics and reporting, including the embedded dashboards they deliver to their customers via Sisense. This POC was the next strategic step in that journey. With the platform stable and maturing, the question became: are we using the best possible approach for replicating data from their MS SQL Server into Snowflake? And more specifically, should we continue developing our custom ingestion approach, or move toward a more ready-to-go solution using Snowflake Openflow? Our client’s business depends on near-real-time data availability. Their operational data lives in MS SQL Server and feeds directly into Snowflake, where it powers analytics and reporting for their customers across retail, telecom, banking, and energy.  These aren't just technical concerns. They directly affect the quality and reliability of the analytics experience delivered to its end customers. The key business drivers behind this evaluation were: Faster availability of incremental data, especially for large transactional tables Lower operational risk and improved robustness Reduced maintenance effort over time Cost optimization at scale Putting Two Approaches to the Test Rather than making a decision based on vendor documentation or assumptions, we designed a structured POC to test both approaches in parallel, on the same three tables and similar data volumes, across four dimensions:  performance cost stability maintainability Option 1: Custom ADF + SQL CDC (Our Existing Solution) The key strength of this approach is control. We define the schema, manage data types, handle transformations during ingestion, and have full visibility into every step of the pipeline. This is the approach we built as part of the initial platform work:  CDC (Change Data Capture) - a native SQL Server feature that tracks only changed rows (inserts, updates, and deletes), eliminating the need to reload entire tables on every run Azure Data Factory (ADF) - Microsoft's cloud orchestration service, used here to move delta data from SQL Server to Azure Blob Storage Snowflake - which then loads and transforms the data using Snowpipe and scheduled tasks It's more engineering-intensive to set up, but the result is a robust, predictable, and highly tunable architecture. Option 2: Snowflake Openflow Openflow is Snowflake's newer, more plug-and-play approach to data ingestion. It uses Change Tracking (CT) on the SQL Server side, a lighter-weight mechanism than CDC,  and ingests data more directly into Snowflake with less custom orchestration required. On paper, it's appealing: faster to configure, fewer moving parts, and tighter integration within the Snowflake ecosystem. But as with any "managed" solution, there are trade-offs. And that's exactly what we needed to quantify. What We Found Performance Both approaches handled initial loads equally well. The gap appeared with delta loads — Openflow's Change Tracking completed incremental updates in 1–2 minutes versus 5–12 minutes for ADF + CDC. For near-real-time use cases, that's a genuine advantage worth noting. Cost This is where the picture shifts dramatically, and where decision makers should pay close attention: Openflow consumed approximately 12.5 Snowflake credits per day, translating to roughly $50/day or ~$1,500/month for this workload alone.  ADF + CDC came in at an estimated $8–15/month   That's not a marginal difference. That's a 100x cost gap on a three-table POC. Now consider what that looks like at scale. Most production environments don't replicate three tables; they replicate dozens, sometimes hundreds. If costs scale proportionally, an Openflow-based architecture could run into tens of thousands of dollars per month for a full production workload, compared to what remains a very modest cost with the custom ADF approach. For a data platform that's meant to be a long-term foundation, the compounding effect of that cost difference is enormous. Over a single year, the gap between these two approaches could easily reach $200,000 or more — money that could instead fund additional data products, analytics capabilities, or engineering capacity. For decision makers evaluating build vs. buy, or assessing the true TCO of a "managed" solution, this kind of analysis is exactly what's needed before committing to a direction. Stability & Maturity The Openflow SQL Server connector was still in preview status at the time of the POC. Runtime and canvas updates caused instability during our testing, and the solution requires ongoing infrastructure coordination. Azure Data Factory, by contrast, is a mature and battle-tested technology with a proven track record in production environments. Architecture & Maintainability Openflow introduced meaningful architectural complexity: requires Snowflake Business Critical edition (for PrivateLink connectivity) adds authentication and data sharing overhead offers limited control over target data types, and  handles soft deletes only, so hard deletes require additional handling Additional Snowflake-side transformations (PARSE_JSON, CLEAN_JSON_DUPLICATES) were also necessary. Our custom ADF solution, while more involved to build: gives full control over schema and transformations  handles deletes cleanly can be extended through a reusable automation procedure that simplifies onboarding new tables over time   Open Flow ADF + CDC Architecture Complexity ⚠️ Requires Business Critical + PrivateLink ✅ Simpler, fewer dependencies Stability  ⚠️ Preview — instability during POC ✅ Mature, production-proven Cost/month ⚠️ ~$1,500 ✅ ~$8–15 Initial load ✅ Comparable ✅ Comparable Delta load speed ✅ ~1–2 min ⚠️ ~5–12 min Onboarding new tables ✅ Fast and easy ⚠️ More setup, but automatable Schema & type control ⚠️ Limited ✅ Full control Delete Handling ⚠️ Soft deletes only ✅ Full delete support Our Recommendation The POC gave our client something genuinely valuable: a data-driven basis for a strategic architectural decision, rather than a choice made on assumption or vendor marketing. Our recommendation leaned toward continuing with the custom ADF + CDC approach as the long-term foundation. Not because Openflow lacks merit, but because the cost differential is substantial, the stability risks are real at this stage of the product's maturity, and the architectural overhead introduces complexity that isn't justified by the performance gain alone. That said, the delta load performance advantage of Openflow is meaningful. As the connector matures and if near-real-time requirements intensify, it remains a viable path to revisit. The POC ensures that if and when that moment comes, the decision will be informed, not reactive. What This Kind of Work Looks Like in Practice In the Sisense ecosystem, you may know us through our plugins and integrations. But this post reflects something equally important: our ability to act as a full-stack data platform partner. What your users see in Sisense is a function of decisions made far upstream. When those decisions lack rigor, the effects show up as latency, data gaps, and rising costs. Running a structured POC like this is often underestimated, but is one of the most valuable things we do for our clients. It's not just about finding the faster or cheaper option. It's about understanding the full picture: performance under realistic conditions, true cost at scale, architectural implications, and long-term maintainability. Every organization's situation is different. The right ingestion architecture depends on your data volumes, latency requirements, existing tooling, team capabilities, and cost constraints.  If you're thinking about your broader data strategy, not just what Sisense can do, but whether everything underneath it is built right, that's a conversation we're well-positioned to have. Thinking about your data platform strategy? Let's talk . QBeeQ is data strategy consulting firm made up of former Sisense employees and customers. We are a Sisense Gold Implementation Partner and Snowflake Select Partner

      Mia Isaacson
      Mia IsaacsonPosted 2 months ago
      0
               
    • 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
      • How-Tos & FAQsChevronRightIcon

      Adding additional dimensions to the Scatter Map widget tooltip

                                                       

      Adding additional dimensions to the Scatter Map widget tooltip Additional dimensions can be added to the hover tooltip of the Scatter Map widget type, beyond the default limit of three, to include more information from other dimensions about a location in a Scatter Map widget. This can be accomplished by using a combination of the widget's before query event  to add additional dimension data for the hover tooltips and the  before datapoint tooltip event to incorporate these dimensions into the tooltip. This method of modifying the query using the "beforequery" event can also be applied to all other widget types   // Add extra parameters to be used in tooltips by modifying query widget.on('beforequery', function (se, ev) { // Initial number of widget metadata panels excluding filter panel widget.initialPanelSizeExcludingFilterPanel = ev.query.metadata.filter((panel) => { return panel.panel !== "scope" }).length; // Extra dimensions to show in tooltip, should return a single result, include as many as needed, just add to array // Jaql Objects can be copied from other widgets from the prism.activeWidget.metadata.panels via the browser console // Modify JAQL as needed, title of JAQL is used in tooltip and can be modified to any string widget.extraDimensionJAQL = [ { "jaql": { "table": "Category", "column": "Category ID", "dim": "[Category.Category ID]", "datatype": "numeric", "merged": true, "agg": "count", "title": "Unique Category ID" } }, { "jaql": { "table": "Country", "column": "Country ID", "dim": "[Country.Country ID]", "datatype": "numeric", "merged": true, "agg": "count", "title": "Unique Country ID" } }, ] // Add to default widget query the extra dimensions to be used in tooltips ev.query.metadata = ev.query.metadata.concat(widget.extraDimensionJAQL) }); // Add extra dimensions added with beforequery object to ScatterMap tooltip widget.on("beforedatapointtooltip", (event, params) => { // Convert query results to include only the additional dimensions, and formatted for tooltip template var onlyAdditionalDimensions = widget.queryResult.$$rows.map((withoutDefaultDimensionOnlyAdditional) => { // Remove the default dimensions, first part of row result array var withoutDefaultDimensionOnlyAdditional = withoutDefaultDimensionOnlyAdditional.slice(widget.initialPanelSizeExcludingFilterPanel) // Format for tooltip template, include title from JAQL var extraDimensionObj = withoutDefaultDimensionOnlyAdditional.map((extraDimensionValue, index) => { // Use extraDimensionJAQL for label in tooltip return { "text": extraDimensionValue.text, "title": widget.extraDimensionJAQL[index].jaql.title } }) return extraDimensionObj }); // Object to store extra dimensions params.context.marker.extraDimension = {}; // Use matching queryIndex for tooltip of additional dimensions params.context.marker.extraDimension.arr = onlyAdditionalDimensions[params.context.marker.queryIndex]; // Template for tooltip, modify as needed params.template = ` <div class='geo-text'>{{ model.marker.name }}</div> <div class='measure-holder' data-ng-if='model.measuresMetadata.sizeTitle'> <div class='measure-title slf-text-secondary'>{{ model.measuresMetadata.sizeTitle }}:</div> <div class='measure-value'>{{ model.marker.sizeObj.text }}</div> </div> <div class='measure-holder' data-ng-if='model.measuresMetadata.colorTitle'> <div class='measure-title slf-text-secondary'>{{ model.measuresMetadata.colorTitle }}:</div> <div class='measure-value'>{{ model.marker.colorObj.text }}</div> </div> <div class='measure-holder details-measure-holder' data-ng-if='model.measuresMetadata.detailsTitle'> <div class='measure-title slf-text-secondary'>{{ model.measuresMetadata.detailsTitle }}:</div> <div class='measure-value' data-ng-if="!model.marker.detailsObj.arr">{{ model.marker.detailsObj.text }}</div> <div class="details-wait" data-ng-if="model.marker.detailsObj.pendingDetails"></div> <div data-ng-if="model.marker.detailsObj.arr"> <div class="details-value" data-ng-repeat="a in model.marker.detailsObj.arr">{{a.text}}</div> <div class="details-counter" data-ng-if="model.marker.detailsObj.hasMore"> <div class="details-counter">...</div> <div class="details-counter"> {{'smap.ttip.firstres'|translate:(args={count:model.marker.detailsObj.arr.length})}}</div> </div> </div> </div> <div data-ng-if="model.marker.extraDimension.arr"> <div data-ng-if='model.measuresMetadata.colorTitle'> <div class='measure-holder' data-ng-repeat="a in model.marker.extraDimension.arr"> <div class='measure-title slf-text-secondary'>{{a.title}}:</div> <div class="measure-value extra-dimension-value">{{a.text}}</div> </div> </div> </div>`; });    The JAQL can be changed to any valid JAQL object , and the tooltip template can also be further modified.

      Jeremy Friedel
      Jeremy FriedelPosted 2 years ago • Last reply 6 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
               
      • Sisense AdministrationChevronRightIcon

      Update and add new Highcharts modules for use in Sisense plugins

                                                                                                                       

      Update and add new Highcharts modules for use in Sisense plugins The JavaScript library framework Highcharts is natively included in Sisense and is utilized in many native Sisense widgets as well as in numerous Sisense plugins. Although Sisense typically does not alter the Sisense Highcharts library version with every release, the versions of Highcharts included in Sisense may change when upgrading to a new major version release. Highcharts can load additional chart types and other types of functionality via JS module files that contain code-adding features such as additional chart types, which can be used within plugins along with additional code to create additional widget types. If a plugin utilizes a Highcharts module, you can source the module directly in the "plugin.json" file's source parameter, as shown in this example:   "source": [ "HighchartModule.js", ],   To determine the current Highcharts version being used in your Sisense version, you can use the "Highcharts" command in the web console while viewing any page on your Sisense server. After identifying the current Highcharts version, you can find the corresponding module hosted on the Highcharts code hosting website using the following URL format: https://code.highcharts.com/${Highcharts_Version}/modules/${module_name}.js  For example: https://code.highcharts.com/6.0.4/modules/heatmap.js You can save this module and upload it to the plugin folder or replace the older module JS file simply by copying and pasting the code directly. Be sure to update the "plugin.json" file to point to the new module file if the file name has changed or if this is the first time the module is included. Simply sourcing the module file in the "plugin.json" file is sufficient to load the module into Highcharts; no further code is required to load the module.

      Jeremy Friedel
      Jeremy FriedelPosted 2 years ago • Last reply 1 year ago
      2