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
    FAQ: Administration
    • Blog banner
      • PySisenseChevronRightIcon

      Bulk Updating BloX Widget Fonts with PySisense

                                                                                                       

      Bulk Updating BloX Widget Fonts with PySisense Overview BloX widgets do not inherit the font configured in a Sisense instance's Look and Feel settings. Changing the tenant level font has no effect on existing BloX widgets, and updating each one by hand through the BloX template editor becomes impractical once a dashboard has more than a few of these widgets, or the same change needs to be applied across many dashboards. This article describes blox_font_updater.py , a Python command-line tool that finds BloX widgets across a Sisense tenant and updates their font using the pySisense library. The tool changes only the font of BloX widgets. It does not modify any other widget type, and it does not change the tenant level Look and Feel font; it edits each BloX widget's own style or font configuration directly. Full coverage is not guaranteed on every widget: BloX widgets are built from open ended custom HTML and JSON, so a small number may still need a manual adjustment after a run. The full source is available on GitHub in this repository . Two ways to change a BloX widget's font A BloX widget's font can be set in two different places, and the tool can use either one, or both: CSS rule : adds a CSS rule to the front of the widget's existing custom style. A wildcard selector targets nearly every element in the widget, including elements such as input fields that do not otherwise inherit a font from their surroundings. Font configuration : sets Sisense's own font setting for the widget directly. This is closer to how a BloX widget's font is normally configured, but depends on the widget's own template reading that setting, so it can miss some elements. Both mechanisms are applied by default. A widget that is already correct for one mechanism is left unchanged for that mechanism even when both are in use, so a run only touches whatever is actually out of date. About PySisense PySisense is a Python SDK for the Sisense REST API, installed via pip. It provides wrappers around common Sisense operations, including dashboard and widget access, and handles authentication, session management, and request errors internally. This tool uses pySisense's SisenseClient and Dashboard classes to discover dashboards and widgets, and to read and write BloX widget definitions. Prerequisites Python 3.13 or later. pySisense and PyYAML, installed with pip install -r requirements.txt . A Sisense API token, ideally belonging to a Sisense administrator. An administrator token lets the tool discover and update BloX widgets across the whole tenant, rather than only dashboards owned by or shared with one user. Setup Copy the example configuration and fill in the Sisense domain and API token: cp config.example.yaml config.yaml The tool is ready to run once domain and token are filled in. The remaining settings control the font change itself and are covered in the next section. Configuration reference # Copy this file to config.yaml and fill in real values: # # cp config.example.yaml config.yaml # # config.yaml contains a real credential once filled in - never send it to # anyone else or commit it to source control. When sharing this tool with # someone else, share this file (config.example.yaml), not a filled-in # config.yaml. # The Sisense server's address. A "http://" or "https://" prefix is fine # here if included - it's ignored either way, since is_ssl below is what # actually decides whether HTTPS or HTTP is used. domain: "your-tenant.sisense.com" # Leave this as true unless the Sisense server uses plain HTTP. is_ssl: true # A Sisense API token. Recommended: a Sisense administrator's token - see # is_user_admin below, and the README's "Getting a Sisense API token" and # "Permissions" sections. token: "YOUR_SISENSE_API_TOKEN" # The kind of widget this tool looks for. Leave this as BloX. widget_type: BloX # Recommended: true, with an administrator token (see token above) - finds # widgets across every dashboard in the tenant, not just ones owned by or # shared with one particular user, and an administrator is also the most # likely to already have the access needed to update most of them. # Set to false to only look at dashboards the API user can already see. is_user_admin: true # Even with is_user_admin above, Sisense can still deny a widget update with # "access denied" if the API user has no owner or edit share on that specific # dashboard - admin access relaxes what can be read, not what can be written. # Set this to true to have the tool work around that automatically: for a # widget that fails this way, it temporarily makes the API user the # dashboard's owner, applies the change, then restores the original owner and # shares. Leave this as false unless that "access denied" failure is actually # being seen - see the README's "Permissions" section for the tradeoffs. use_temporary_ownership: false # The settings below control what the tool does by default when run # without the matching command-line option. Anything here can still be # overridden per run - for example, --font "Georgia, serif" always wins over # default_font_family below, no matter what it's set to. # The CSS selector used when default_font_mode is css or both (see README.md). default_selector: ".blox-slides *" # The font used for the change - a full CSS font stack (e.g. with fallback # fonts). Used as-is for default_font_mode: css. For default_font_mode: # config, only the first name is used (e.g. "Arial" here) - that's the # typical, well-supported value for Sisense's font config field, even though # it has also been observed to accept a full stack like this one directly. default_font_family: "Arial, Helvetica, sans-serif" # One of: css, config, both - see "Two ways to change the font" in # blox_font_updater.py, or the README, for what each one does. "both" is # the default here for the widest out-of-the-box coverage. default_font_mode: both # Maximum number of widgets a single run will touch. 0 (or a negative # number) means no limit. default_max_widgets: 10 domain , is_ssl , token : connection details for the Sisense instance. A http:// or https:// prefix on domain is ignored either way; is_ssl decides the scheme. widget_type : left as BloX . is_user_admin : true to discover widgets across the whole tenant with an administrator token, false to only look at dashboards the token's own user can already see. default_selector : the CSS selector used when default_font_mode is css or both . default_font_family : the font applied. A full CSS font stack is used as is for the CSS rule; only the first name is used for the font configuration field. default_font_mode : css , config , or both , as described above. default_max_widgets : how many widgets a single run touches. 0 or a negative number removes the limit. use_temporary_ownership : false by default. Set to true to have the tool temporarily take ownership of a dashboard when a widget update is denied with "access denied," even with is_user_admin set. Each of these can be overridden for a single run with a matching command-line option ( --selector , --font , --font-mode , --max-widgets ) without changing config.yaml . Usage Run with no options to preview which widgets would change, without writing anything: python blox_font_updater.py Apply the change to a single widget first, then confirm it in Sisense: python blox_font_updater.py --apply --max-widgets 1 If the result is not correct, every change made by the tool can be reverted: python blox_font_updater.py --undo Once confirmed, apply the change across the tenant: python blox_font_updater.py --apply --all Common use cases Rolling out a font change across a tenant Set default_selector , default_font_family , and default_font_mode in config.yaml to the desired values, then run the sequence in Usage: preview, apply to one widget, confirm, apply to all. Using a different font for a single run, without changing config.yaml python blox_font_updater.py --apply --font "Georgia, serif" --font-mode config Reducing log output on a large run python blox_font_updater.py --apply --all --quiet Apply Change to One Specific Widget ID Only python blox_font_updater.py --apply --dashboard-id <dashboardId> --widget-id <widgetId> Logs and backups Every run writes to logs/blox_font_update.log . Before any widget is changed, its complete prior definition is recorded in logs/blox_font_backup.json . --undo reads that file and restores every change that has not already been undone, most recent first. Both paths can be changed with --log-file and --backup-file . Full option reference usage: blox_font_updater.py [-h] [--config PATH] [--selector CSS_SELECTOR] [--font FONT_FAMILY] [--font-mode {css,config,both}] [--max-widgets N] [--all] [--dashboard-id ID] [--widget-id ID] [--apply] [--force] [--undo] [--backup-file PATH] [--log-file PATH] [--quiet] [--verbose] Change the font used by BloX widgets on a Sisense instance. options: -h, --help show this help message and exit --config PATH Path to config.yaml. --selector CSS_SELECTOR Override default_selector from config.yaml, for this run only. Normally set in config.yaml instead. Used if neither is set: '.blox-slides *'. --font FONT_FAMILY Override default_font_family from config.yaml, for this run only. Normally set in config.yaml instead. Used if neither is set: 'Arial, Helvetica, sans- serif'. --font-mode {css,config,both} Override default_font_mode from config.yaml, for this run only - normally set in config.yaml instead. Used if neither is set: 'both'. 'css' adds a CSS rule that directly targets nearly every element, including custom HTML and input fields. 'config' sets Sisense's simpler built-in font setting instead, which some widgets' own elements (including input fields) may not pick up. Neither is guaranteed to cover everything - BloX templates vary - so 'both' applies both changes for the best chance of full coverage. --max-widgets N Override default_max_widgets from config.yaml, for this run only. Normally set in config.yaml instead. Used if neither is set: 10. 0 or a negative number means no limit - same as --all. --all Process every matching widget, with no limit. Overrides --max-widgets. --dashboard-id ID Target one already-known widget directly by dashboard and widget id, instead of scanning the tenant. Must be used together with --widget-id. --max-widgets/--all are ignored in this mode. --widget-id ID Used together with --dashboard-id. See --dashboard-id. --apply Actually write changes to Sisense. Without this flag, the tool only previews what it would do. --force Re-apply even to widgets that already look up to date for the selected --font-mode. --undo Revert every not-yet-undone change recorded in --backup-file, then exit. --backup-file PATH Where change backups are stored. --log-file PATH Where the run log is written. --quiet Only log the scan summary and any warnings/errors - skip the per-widget lines. Useful for large runs. --verbose Show full style strings in the log instead of a truncated preview. Use case Using blox_font_updater.py instead of manual BloX template edits offers several advantages: Applies a font change consistently across every BloX widget in a tenant, without opening each widget individually. Provides automatic rollback through the backup file, without a separate export or snapshot step. Supports two independent font change mechanisms, so a widget that does not respond to one still receives the font through the other. Reduces the time needed to test a change safely, through a preview mode and a single widget run before a full rollout. Summary blox_font_updater.py gives Sisense administrators a repeatable way to change the font of BloX widgets across a tenant from the command line, with a preview step, a backup of every change, and full reversal through --undo . config.yaml serves as the source of truth for the selector, font, and mode used, and can be adjusted per environment without changing the script itself. The full source is available on GitHub and can be modified as needed. """ bloxFontUpdater - bulk-update the font used by Sisense BloX widgets. BloX widgets don't inherit Sisense's tenant-level "Look and Feel" font, so this tool sets one directly, across every BloX widget it finds, by adding a CSS rule, by setting Sisense's own font config field, or both (see resolveSetting/applyChanges below and "Two ways to change the font" in README.md). Preview-only by default; every change is backed up and reversible with --undo. Full documentation, including configuration and the two font-change mechanisms: README.md Command-line options: --help python blox_font_updater.py # preview only, changes nothing python blox_font_updater.py --apply --max-widgets 1 python blox_font_updater.py --undo """ import argparse import base64 import copy import json import logging import os import sys from datetime import datetime, timezone _installHint = ( "Missing dependency: {module}.\n\n" "Install the required packages first, for example:\n" " pip install -r requirements.txt\n" "or, with uv:\n" " uv sync\n" ) try: import yaml except ImportError: sys.exit(_installHint.format(module="PyYAML")) try: from pysisense import SisenseClient, Dashboard except ImportError: sys.exit(_installHint.format(module="pysisense")) # --------------------------------------------------------------------------- # Fallback values, used only for a setting that is given on neither the # command line nor in config.yaml. Most users should set default_selector, # default_font_family, default_font_mode, and default_max_widgets in # config.yaml instead of changing these - see config.example.yaml. # --------------------------------------------------------------------------- fallbackSelector = ".blox-slides *" fallbackFontFamily = "Arial, Helvetica, sans-serif" # "both" gives the widest out-of-the-box coverage, so it's the fallback - # see "Two ways to change the font" in README.md. fallbackFontMode = "both" # Safety cap on how many widgets a single run will touch. Set # default_max_widgets to 0 in config.yaml (or pass --all) to remove the cap. fallbackMaxWidgets = 10 # The only valid values for config.yaml's default_font_mode (or --font-mode). fontModeChoices = ("css", "config", "both") scriptDir = os.path.dirname(os.path.abspath(__file__)) defaultConfigPath = os.path.join(scriptDir, "config.yaml") defaultLogFile = os.path.join(scriptDir, "logs", "blox_font_update.log") defaultBackupFile = os.path.join(scriptDir, "logs", "blox_font_backup.json") # Length a style preview is truncated to in normal (non --verbose) logging, # so a widget with a very long style string doesn't flood the terminal. stylePreviewLength = 100 # Fields Sisense manages internally and does not expect to receive back when # saving a widget - present on data read from the API, but absent from a # widget definition when it is written. serverManagedWidgetFields = ( "oid", "_id", "owner", "userId", "created", "lastUpdated", "instanceType", "dashboardid", ) def setupLogger(logFile): """Log to both the console and logFile, so a run can be reviewed afterwards.""" # dirname is "" for a bare filename (e.g. --log-file run.log, meaning # "current directory") - makedirs("") raises, so only call it when # there's an actual directory to create. logDir = os.path.dirname(logFile) if logDir: os.makedirs(logDir, exist_ok=True) logger = logging.getLogger("bloxFontUpdater") logger.setLevel(logging.INFO) logger.handlers.clear() # avoid duplicate output if setupLogger is ever called twice formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") fileHandler = logging.FileHandler(logFile) fileHandler.setFormatter(formatter) logger.addHandler(fileHandler) consoleHandler = logging.StreamHandler() consoleHandler.setFormatter(formatter) logger.addHandler(consoleHandler) return logger def previewStyle(styleText, verbose): """Shorten a style string for logging, unless --verbose was requested.""" if verbose or len(styleText) <= stylePreviewLength: return styleText return styleText[:stylePreviewLength] + f"... ({len(styleText)} chars total, use --verbose to see all)" def buildCssRule(selector, fontFamily): """Build the single CSS rule that gets added to the front of a widget's style.""" return f"{selector}{{font-family: {fontFamily} !important}}" def extractPrimaryFontName(fontFamily): """ Turn a CSS font-family value into just its first font name, e.g. "Arial, Helvetica, sans-serif" -> "Arial", or '"Open Sans", sans-serif' -> "Open Sans". Used for Sisense's font config field (style.currentConfig. fontFamily), which is normally set to a single font name like this - that field has also been observed to accept a full comma-separated stack, but a single name is the more typical, well-supported value for it. """ firstFont = fontFamily.split(",")[0].strip() return firstFont.strip("'\"") def mergeStyle(existingStyle, newRule): """ Add newRule to the front of a widget's existing style, without altering the existing content in any way. A BloX widget's style string may be missing entirely, empty, a single rule, several rules concatenated together, or CSS left over from manual editing that doesn't fully make sense on its own. In every case, newRule is placed at the front and whatever was already there is kept exactly as it was - this tool never tries to interpret or "clean up" existing CSS. """ existingStyle = existingStyle or "" if not existingStyle.strip(): return newRule return f"{newRule} {existingStyle}" def ruleAlreadyApplied(existingStyle, newRule): """True if newRule is already the first thing in the style string.""" return (existingStyle or "").strip().startswith(newRule) def loadBackup(path): """Read the JSON list of previously-applied changes, or [] if none exist yet.""" if not os.path.exists(path): return [] with open(path, "r") as backupFile: try: return json.load(backupFile) except json.JSONDecodeError: return [] def saveBackup(path, records): """Persist the backup list. Called immediately after every change, so an interrupted run never loses track of widgets it already modified.""" # See setupLogger for why this only calls makedirs when there's an # actual directory component. backupDir = os.path.dirname(path) if backupDir: os.makedirs(backupDir, exist_ok=True) with open(path, "w") as backupFile: json.dump(records, backupFile, indent=2) def getWidgetEndpoint(dashboardId, widgetId, isAdmin): """Endpoint used to read a single widget's full definition.""" endpoint = f"/api/v1/dashboards/{dashboardId}/widgets/{widgetId}" if isAdmin: endpoint += "?adminAccess=true" return endpoint def putWidgetEndpoint(dashboardId, widgetId): """Endpoint used to save a widget - the same one the Sisense web UI itself uses when a widget is edited and saved.""" return f"/api/dashboards/{dashboardId}/widgets/{widgetId}" def buildPutPayload(widget): """Strip the server-managed fields Sisense does not expect back when a widget is saved (see serverManagedWidgetFields).""" return {key: value for key, value in widget.items() if key not in serverManagedWidgetFields} def describeErrorResponse(response): """ Best-effort description of a failed request, for logging. `requests.Response` objects are falsy for any non-2xx status - `if response` is True only when the request actually succeeded, not merely when a response was received. Checking `response is not None` here is what actually distinguishes "no response at all" (e.g. a network error) from "the server responded with an error", so the real error detail (e.g. "Widget not found") gets logged instead of a misleading "No response received." Also falls back gracefully if the error body isn't valid JSON. """ if response is None: return "No response received." try: return response.json() except ValueError: return response.text or f"HTTP {response.status_code}" def decodeJwtUserId(token): """ Read the "user" claim out of a Sisense API token, without verifying its signature - used only to learn the API user's own id for use_temporary_ownership, not for any security decision. Sisense API tokens are JWTs: three dot-separated, base64url-encoded segments (header, payload, signature). The payload segment decodes to a JSON object containing, among other things, "user" (the token owner's own id). Returns None if token isn't a decodable JWT of this shape. """ try: payloadSegment = token.split(".")[1] padded = payloadSegment + "=" * (-len(payloadSegment) % 4) payload = json.loads(base64.urlsafe_b64decode(padded)) except (IndexError, ValueError, TypeError): return None return payload.get("user") def getDashboardOwnerId(apiClient, dashboardId, logger): """Look up the current owner of a dashboard, for use_temporary_ownership - the same admin endpoint pysisense's own get_all_dashboards() reads from, scoped to one dashboard.""" response = apiClient.get(f"/api/v1/dashboards/admin?dashboardType=owner&id={dashboardId}&asObject=false") if not response or not response.ok: logger.error(f"Could not look up the current owner of dashboard {dashboardId}: {describeErrorResponse(response)}") return None matches = response.json() if not matches: logger.error(f"Could not look up the current owner of dashboard {dashboardId}: dashboard not found.") return None return matches[0].get("owner") def getDashboardShares(apiClient, dashboardId, logger): """Read a dashboard's current share list, so use_temporary_ownership can restore it exactly after a temporary ownership change.""" response = apiClient.get(f"/api/shares/dashboard/{dashboardId}?adminAccess=true") if not response or not response.ok: logger.error(f"Could not read the current shares for dashboard {dashboardId}: {describeErrorResponse(response)}") return None return response.json().get("sharesTo", []) def changeDashboardOwner(apiClient, dashboardId, newOwnerId, useAdminAccess, logger): """ Change who owns a dashboard - the same endpoint pysisense's own add_widget_script() helper uses to temporarily take ownership of a dashboard it doesn't otherwise have edit rights on. useAdminAccess should be True when taking ownership (the API user typically isn't already a party to the dashboard) and False when handing it back (the API user is the current owner at that point, so no admin override is needed). """ endpoint = f"/api/v1/dashboards/{dashboardId}/change_owner" if useAdminAccess: endpoint += "?adminAccess=true" response = apiClient.post(endpoint, data={"ownerId": newOwnerId, "originalOwnerRule": "edit"}) if not response or not response.ok: logger.error(f"Could not change the owner of dashboard {dashboardId} to {newOwnerId}: {describeErrorResponse(response)}") return False return True def restoreDashboardShares(apiClient, dashboardId, shares, logger): """Write a previously-captured share list (from getDashboardShares) back onto a dashboard, after a temporary ownership change.""" payload = { "sharesTo": [ { "shareId": share["shareId"], "type": share["type"], "rule": share.get("rule", "edit"), "subscribe": share.get("subscribe", False), } for share in shares ] } response = apiClient.post(f"/api/shares/dashboard/{dashboardId}", data=payload) if not response or not response.ok: logger.error(f"Could not restore the original shares for dashboard {dashboardId}: {describeErrorResponse(response)}") return False return True def withTemporaryOwnership(apiClient, dashboardId, apiUserId, logger, action): """ Make apiUserId the temporary owner of dashboardId, run action(), then restore the original owner and shares - even if action() raises, or the restore step itself fails partway. This is the use_temporary_ownership fallback (see config.example.yaml and README.md "Permissions"): Sisense can deny a widget write with 403 even to an admin token, when the token's user has no owner/edit share on that specific dashboard. Mirrors the ownership-swap-and-restore pattern pysisense's own add_widget_script() uses for widget scripts, applied here to widget field writes instead. Unlike that pysisense helper, restoration here always runs - including when action() raises or the write itself fails - so a dashboard is never left owned by the tool's API user because of a mid-run error. Returns (result, attempted): result is action()'s return value, or None if the swap could not be attempted at all (owner/shares lookup failed, or the ownership change itself failed - nothing was touched in that case). attempted is True whenever action() actually ran. """ originalOwnerId = getDashboardOwnerId(apiClient, dashboardId, logger) if not originalOwnerId: return None, False if originalOwnerId == apiUserId: # Already the owner - a plain write should not have failed with 403 # for ownership reasons, so there's nothing useful to swap here. return None, False originalShares = getDashboardShares(apiClient, dashboardId, logger) if originalShares is None: return None, False if not changeDashboardOwner(apiClient, dashboardId, apiUserId, True, logger): return None, False logger.warning(f"Temporarily changed the owner of dashboard {dashboardId} to the API user, to apply the font change.") try: result = action() finally: sharesRestored = restoreDashboardShares(apiClient, dashboardId, originalShares, logger) ownerRestored = changeDashboardOwner(apiClient, dashboardId, originalOwnerId, False, logger) if sharesRestored and ownerRestored: logger.warning(f"Restored the original owner and shares for dashboard {dashboardId}.") else: logger.error( f"Dashboard {dashboardId} may still be owned by the API user - restoring shares " f"{'succeeded' if sharesRestored else 'FAILED'} and restoring ownership " f"{'succeeded' if ownerRestored else 'FAILED'}. Check this dashboard's owner and " "shares in Sisense directly." ) return result, True def putWidgetWithOwnershipFallback(apiClient, dashboardId, widgetId, payload, useTemporaryOwnership, apiUserId, logger): """ Save a widget, retrying once through withTemporaryOwnership if the first attempt is denied with 403 and use_temporary_ownership is enabled. Returns (response, usedTemporaryOwnership) - response is whichever attempt actually produced one (None if the fallback couldn't even be attempted). """ response = apiClient.put(putWidgetEndpoint(dashboardId, widgetId), data=payload) deniedByPermissions = response is not None and response.status_code == 403 if not (deniedByPermissions and useTemporaryOwnership and apiUserId): return response, False logger.warning( f"Widget {widgetId} on dashboard {dashboardId} was denied (403) - retrying with " "temporary dashboard ownership since use_temporary_ownership is enabled." ) fallbackResponse, attempted = withTemporaryOwnership( apiClient, dashboardId, apiUserId, logger, lambda: apiClient.put(putWidgetEndpoint(dashboardId, widgetId), data=payload), ) if not attempted: # Swap couldn't even be attempted - report the original 403, not "no response". return response, False return fallbackResponse, True def fetchWidget(apiClient, dashboardId, widgetId, isAdmin, logger): """Fetch a widget's full current definition - the source of truth this tool reads from, diffs against, and eventually writes back to.""" response = apiClient.get(getWidgetEndpoint(dashboardId, widgetId, isAdmin)) if not response or not response.ok: logger.error(f"Could not fetch widget {widgetId} on dashboard {dashboardId} - skipping it. {describeErrorResponse(response)}") return None return response.json() def findTargetWidgets(apiClient, dashboards, widgetType, isAdmin, maxWidgets, logger): """ Scan dashboards for widgets matching widgetType, returning up to maxWidgets matches as a list of dicts with dashboard/widget IDs and titles. Pass maxWidgets=None to scan every dashboard with no cap. Stops scanning as soon as maxWidgets is reached, so a small run does not pay the cost of scanning every dashboard in the tenant. """ found = [] for dashboard in dashboards: if maxWidgets is not None and len(found) >= maxWidgets: break if isAdmin: # Admin export: works for dashboards not shared with this API user. response = apiClient.get( f"/api/v1/dashboards/export?dashboardIds={dashboard['oid']}&adminAccess=true" ) if not response or not response.ok: logger.warning( f"Could not export dashboard {dashboard['oid']} ({dashboard.get('title', '')}) - " f"skipping. {describeErrorResponse(response)}" ) continue exportedDashboards = response.json() exported = exportedDashboards[0] if exportedDashboards else {} candidateWidgets = exported.get("widgets") or [] else: # Only dashboards this API user can already see. response = apiClient.get(f"/api/v1/dashboards/{dashboard['oid']}/widgets") if not response or not response.ok: logger.warning( f"Could not fetch widgets for dashboard {dashboard['oid']} ({dashboard.get('title', '')}) - " f"skipping. {describeErrorResponse(response)}" ) continue candidateWidgets = response.json() or [] for widget in candidateWidgets: if widget.get("type") != widgetType: continue found.append( { "dashboardId": dashboard["oid"], "dashboardTitle": dashboard.get("title", ""), "widgetId": widget["oid"], "widgetTitle": widget.get("title", ""), } ) if maxWidgets is not None and len(found) >= maxWidgets: break return found def undoChanges(apiClient, backupFile, logger, useTemporaryOwnership=False, apiUserId=None): """Restore every not-yet-undone widget in backupFile to its recorded state.""" records = loadBackup(backupFile) pending = [record for record in records if not record.get("undone")] if not pending: logger.info("Nothing to undo - no pending changes found in the backup file.") return logger.info(f"Reverting {len(pending)} previously applied change(s)...") # Revert most-recent-first, like a normal undo stack. for record in reversed(records): if record.get("undone"): continue # A hand-edited or corrupted backup file shouldn't be able to abort # the whole run - skip just this record and keep going. missingKeys = [key for key in ("dashboardId", "widgetId", "originalWidget") if key not in record] if missingKeys: logger.error(f"Skipping a backup record missing {', '.join(missingKeys)} - it cannot be undone.") continue dashboardId = record["dashboardId"] widgetId = record["widgetId"] originalWidget = record["originalWidget"] response, _ = putWidgetWithOwnershipFallback( apiClient, dashboardId, widgetId, buildPutPayload(originalWidget), useTemporaryOwnership, apiUserId, logger, ) if response and response.ok: record["undone"] = True record["undoneAt"] = datetime.now(timezone.utc).isoformat() logger.info(f"Reverted widget {widgetId} ({record.get('widgetTitle', '')}) on dashboard {dashboardId}.") else: logger.error(f"Failed to revert widget {widgetId}: {describeErrorResponse(response)}") # Save after every widget, not just at the end, so a partial undo run isn't lost. saveBackup(backupFile, records) logger.info("Undo complete.") def applyChanges( apiClient, candidates, selector, fontFamily, fontMode, apply, force, backupFile, isAdmin, quiet, verbose, logger, useTemporaryOwnership=False, apiUserId=None, ): """ Apply the configured font change(s) to each candidate widget. fontMode controls which of the two font-change mechanisms are used (see "Two ways to change the font" in README.md): - "css": add newRule to the front of style.currentCard.style - "config": set style.currentConfig.fontFamily to a single font name - "both": do both A widget is skipped only if every mechanism in use is already up to date for it; otherwise, only the mechanism(s) that actually need a change are applied (so, in "both" mode, a widget that already has the right CSS rule but the wrong font config only gets its font config updated). """ updateCss = fontMode in ("css", "both") updateConfig = fontMode in ("config", "both") newRule = buildCssRule(selector, fontFamily) if updateCss else None primaryFontName = extractPrimaryFontName(fontFamily) if updateConfig else None if updateCss: logger.info(f"CSS rule to apply: {newRule}") if updateConfig: logger.info(f"Font config value to apply (style.currentConfig.fontFamily): {primaryFontName!r}") backupRecords = loadBackup(backupFile) appliedCount = 0 skippedCount = 0 for candidate in candidates: # Re-fetch fresh rather than reuse the discovery payload, which has a # different shape (bulk export vs. single-widget document) and may # be stale. widget = fetchWidget(apiClient, candidate["dashboardId"], candidate["widgetId"], isAdmin, logger) if widget is None: continue # Read state for both mechanisms up front, even if only one is in # use, so the checks below don't need to branch on fontMode again. styleBlock = widget.get("style", {}) or {} currentCard = styleBlock.get("currentCard", {}) or {} currentConfig = styleBlock.get("currentConfig", {}) or {} existingStyle = currentCard.get("style", "") existingFontFamily = currentConfig.get("fontFamily", "") # A mechanism needs updating only if it's in use and not already # correct (or --force is set). This is what lets "both" mode touch # only the mechanism that's actually out of date on a given widget. needsCssChange = updateCss and (force or not ruleAlreadyApplied(existingStyle, newRule)) needsConfigChange = updateConfig and (force or existingFontFamily != primaryFontName) if not needsCssChange and not needsConfigChange: if not quiet: logger.info( f"Skipping widget {candidate['widgetId']} ({candidate['widgetTitle']!r}) - " "already up to date. Use --force to re-apply anyway." ) skippedCount += 1 continue updatedStyle = mergeStyle(existingStyle, newRule) if needsCssChange else existingStyle # Dry run: log the preview, touch nothing. if not apply: if not quiet: previewLines = [ f"[DRY RUN] Would update widget {candidate['widgetId']} " f"({candidate['widgetTitle']!r} on dashboard {candidate['dashboardTitle']!r}):" ] if needsCssChange: previewLines.append(f" CSS style - old: {previewStyle(existingStyle, verbose)!r}") previewLines.append(f" CSS style - new: {previewStyle(updatedStyle, verbose)!r}") if needsConfigChange: previewLines.append(f" Font config - old: {existingFontFamily!r}") previewLines.append(f" Font config - new: {primaryFontName!r}") logger.info("\n".join(previewLines)) continue # Back up before mutating, and save immediately so --undo survives an # interrupted run. deepcopy is required, not optional: `widget` is # mutated in place below, and saveBackup() re-serializes this whole # list on every call - a bare reference here would let a later # widget's save silently overwrite this "before" snapshot with # whatever `widget` looks like by then. backupRecords.append( { "timestamp": datetime.now(timezone.utc).isoformat(), "dashboardId": candidate["dashboardId"], "widgetId": candidate["widgetId"], "widgetTitle": candidate["widgetTitle"], "fontModeUsed": fontMode, "selectorUsed": selector if needsCssChange else None, "fontUsed": fontFamily, "originalWidget": copy.deepcopy(widget), "undone": False, } ) saveBackup(backupFile, backupRecords) # Mutate only what's out of date - a widget already correct for one # mechanism keeps that field untouched, even in "both" mode. if needsCssChange: widget.setdefault("style", {}).setdefault("currentCard", {})["style"] = updatedStyle if needsConfigChange: widget.setdefault("style", {}).setdefault("currentConfig", {})["fontFamily"] = primaryFontName response, usedTemporaryOwnership = putWidgetWithOwnershipFallback( apiClient, candidate["dashboardId"], candidate["widgetId"], buildPutPayload(widget), useTemporaryOwnership, apiUserId, logger, ) if response and response.ok: appliedCount += 1 if usedTemporaryOwnership: backupRecords[-1]["usedTemporaryOwnership"] = True saveBackup(backupFile, backupRecords) if not quiet: # Report exactly which mechanism(s) were touched for this # widget, since in "both" mode it may only have been one. changedParts = [ label for label, changed in (("CSS style", needsCssChange), ("font config", needsConfigChange)) if changed ] viaOwnershipSwap = " (via temporary dashboard ownership)" if usedTemporaryOwnership else "" logger.info( f"Updated widget {candidate['widgetId']} ({candidate['widgetTitle']!r}) " f"on dashboard {candidate['dashboardId']} - changed: {', '.join(changedParts)}{viaOwnershipSwap}." ) else: logger.error(f"Failed to update widget {candidate['widgetId']}: {describeErrorResponse(response)}") backupRecords[-1]["updateFailed"] = True saveBackup(backupFile, backupRecords) return appliedCount, skippedCount def parseArgs(): """Define and parse the tool's command-line flags.""" parser = argparse.ArgumentParser( description="Change the font used by BloX widgets on a Sisense instance.", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--config", dest="configPath", default=defaultConfigPath, metavar="PATH", help="Path to config.yaml.") # Font settings default to None, not a real value, so main() can tell # whether each was actually passed - see resolveSetting(). parser.add_argument( "--selector", dest="selector", default=None, metavar="CSS_SELECTOR", help="Override default_selector from config.yaml, for this run only. Normally set " f"in config.yaml instead. Used if neither is set: {fallbackSelector!r}.", ) parser.add_argument( "--font", dest="fontFamily", default=None, metavar="FONT_FAMILY", help="Override default_font_family from config.yaml, for this run only. Normally set " f"in config.yaml instead. Used if neither is set: {fallbackFontFamily!r}.", ) parser.add_argument( "--font-mode", dest="fontMode", choices=fontModeChoices, default=None, help=( "Override default_font_mode from config.yaml, for this run only - normally set " f"in config.yaml instead. Used if neither is set: {fallbackFontMode!r}. " "'css' adds a CSS rule that directly targets nearly every element, including custom " "HTML and input fields. 'config' sets Sisense's simpler built-in font setting instead, " "which some widgets' own elements (including input fields) may not pick up. Neither is " "guaranteed to cover everything - BloX templates vary - so 'both' applies both changes " "for the best chance of full coverage." ), ) parser.add_argument( "--max-widgets", dest="maxWidgets", type=int, default=None, metavar="N", help="Override default_max_widgets from config.yaml, for this run only. Normally set " f"in config.yaml instead. Used if neither is set: {fallbackMaxWidgets}. " "0 or a negative number means no limit - same as --all.", ) parser.add_argument( "--all", dest="allWidgets", action="store_true", help="Process every matching widget, with no limit. Overrides --max-widgets.", ) parser.add_argument( "--dashboard-id", dest="dashboardId", default=None, metavar="ID", help="Target one already-known widget directly by dashboard and widget id, instead of " "scanning the tenant. Must be used together with --widget-id. --max-widgets/--all are " "ignored in this mode.", ) parser.add_argument( "--widget-id", dest="widgetId", default=None, metavar="ID", help="Used together with --dashboard-id. See --dashboard-id.", ) # Run control - what this particular invocation actually does. parser.add_argument( "--apply", dest="apply", action="store_true", help="Actually write changes to Sisense. Without this flag, the tool only previews what it would do.", ) parser.add_argument( "--force", dest="force", action="store_true", help="Re-apply even to widgets that already look up to date for the selected --font-mode.", ) parser.add_argument( "--undo", dest="undo", action="store_true", help="Revert every not-yet-undone change recorded in --backup-file, then exit.", ) # Where state is read from / written to. parser.add_argument("--backup-file", dest="backupFile", default=defaultBackupFile, metavar="PATH", help="Where change backups are stored.") parser.add_argument("--log-file", dest="logFile", default=defaultLogFile, metavar="PATH", help="Where the run log is written.") # Output verbosity. parser.add_argument( "--quiet", dest="quiet", action="store_true", help="Only log the scan summary and any warnings/errors - skip the per-widget lines. " "Useful for large runs.", ) parser.add_argument( "--verbose", dest="verbose", action="store_true", help="Show full style strings in the log instead of a truncated preview.", ) return parser.parse_args() def loadConfig(configPath): """Read and sanity-check config.yaml, exiting with a plain-language explanation (not a Python traceback) for the mistakes a new user is most likely to make: no config file yet, broken YAML, or placeholder values left unfilled.""" if not os.path.exists(configPath): sys.exit( f"Could not find {configPath}.\n\n" "On first use, copy the example config and fill in the required values:\n\n" " cp config.example.yaml config.yaml\n\n" "Then edit config.yaml with the Sisense domain and API token to use - see README.md." ) if not os.path.isfile(configPath): sys.exit(f"{configPath} is not a file (is it a directory?). Point --config at a config.yaml file.") with open(configPath, "r") as configFile: try: config = yaml.safe_load(configFile) or {} except yaml.YAMLError as error: sys.exit(f"{configPath} is not valid YAML: {error}\n\nCheck for typos, especially around quotes and indentation.") if not isinstance(config, dict): sys.exit(f"{configPath} must contain key: value settings, not a {type(config).__name__}. See config.example.yaml.") placeholderValues = {"", "your-tenant.sisense.com", "YOUR_SISENSE_API_TOKEN"} missingKeys = [key for key in ("domain", "token") if str(config.get(key, "")).strip() in placeholderValues] if missingKeys: sys.exit( f"{configPath} still has placeholder value(s) for: {', '.join(missingKeys)}.\n\n" "Edit config.yaml with the real Sisense domain and API token to use - see README.md." ) return config def resolveSetting(cliValue, config, configKey, fallback): """ Decide the effective value for a setting that can be given in three places, checked in this order: a command-line flag, a value in config.yaml, or this tool's own built-in fallback. """ if cliValue is not None: return cliValue configValue = config.get(configKey) if configValue is not None: return configValue return fallback def requireNonEmptyString(value, settingDescription): """Exit with a clear error if value isn't a usable non-empty string - catches mistakes like an empty --selector/--font flag or a default_font_family: "" in config.yaml, either of which would otherwise silently produce a broken CSS rule or font name later on.""" if not isinstance(value, str) or not value.strip(): sys.exit(f"Invalid {settingDescription}: {value!r} - must be a non-empty string.") def parseWholeNumber(value): """ Interpret value as a whole number, or return None if it isn't one. Used for default_max_widgets, which - unlike --max-widgets - isn't type-checked by argparse and can be any YAML value. Deliberately stricter than Python's own int(): int(10.5) truncates to 10 and int(True) succeeds as 1, both of which would silently accept a config mistake instead of rejecting it. """ if isinstance(value, bool): return None if isinstance(value, int): return value if isinstance(value, float): return int(value) if value.is_integer() else None if isinstance(value, str): try: return int(value.strip()) except ValueError: return None return None def main(): """ Entry point: parse arguments, load configuration, then either undo a previous run or scan for candidate widgets and apply font changes to them. This function only wires the pieces together - all the actual logic lives in the functions it calls. """ # Logging starts as early as possible so even a bad --log-file value or a # config problem below gets written to the console consistently. args = parseArgs() logger = setupLogger(args.logFile) # config.yaml supplies the Sisense connection details (read directly by # SisenseClient) plus the default_* settings resolved below. config = loadConfig(args.configPath) isAdmin = bool(config.get("is_user_admin")) widgetType = config.get("widget_type", "BloX") # use_temporary_ownership (see config.example.yaml and README.md # "Permissions") needs the API user's own id, read directly out of the # token rather than via a separate lookup call. useTemporaryOwnership = bool(config.get("use_temporary_ownership")) apiUserId = decodeJwtUserId(config.get("token", "")) if useTemporaryOwnership else None if useTemporaryOwnership and not apiUserId: logger.warning( "use_temporary_ownership is enabled in config.yaml, but the API user's id could not be " "read from token - this fallback will be skipped for the rest of this run." ) apiClient = SisenseClient(config_file=args.configPath, debug=False) # --undo works entirely from the backup file and skips everything below. if args.undo: undoChanges(apiClient, args.backupFile, logger, useTemporaryOwnership, apiUserId) return # Priority: CLI flag, then config.yaml, then built-in fallback - see # resolveSetting(). selector = resolveSetting(args.selector, config, "default_selector", fallbackSelector) requireNonEmptyString(selector, "selector (--selector / default_selector)") fontFamily = resolveSetting(args.fontFamily, config, "default_font_family", fallbackFontFamily) requireNonEmptyString(fontFamily, "font (--font / default_font_family)") fontMode = resolveSetting(args.fontMode, config, "default_font_mode", fallbackFontMode) if fontMode not in fontModeChoices: # Only reachable via a typo in config.yaml's default_font_mode - # argparse itself already restricts --font-mode to fontModeChoices. sys.exit( f"Invalid default_font_mode in config.yaml: {fontMode!r}. Must be one of: " f"{', '.join(fontModeChoices)}." ) # default_max_widgets isn't type-checked by argparse the way --max-widgets # is (it can only come from config.yaml here), so it needs its own # validation before being used as a number. rawMaxWidgets = resolveSetting(args.maxWidgets, config, "default_max_widgets", fallbackMaxWidgets) maxWidgetsSetting = parseWholeNumber(rawMaxWidgets) if maxWidgetsSetting is None: sys.exit(f"Invalid default_max_widgets in config.yaml: {rawMaxWidgets!r} - must be a whole number.") # --all, or a zero/negative limit from either source, both mean "no cap". maxWidgets = None if (args.allWidgets or maxWidgetsSetting <= 0) else maxWidgetsSetting if bool(args.dashboardId) != bool(args.widgetId): sys.exit("--dashboard-id and --widget-id must be used together.") if args.dashboardId and args.widgetId: # Skip the tenant scan entirely and go straight to one already-known # widget - useful for retrying, or verifying, a specific widget # without depending on dashboard discovery order (which can surface # unrelated widgets, or the same widget twice, on a tenant whose # dashboards have data quirks of their own). widget = fetchWidget(apiClient, args.dashboardId, args.widgetId, isAdmin, logger) if widget is None: sys.exit(f"Could not fetch widget {args.widgetId} on dashboard {args.dashboardId} - see the log above.") if widget.get("type") != widgetType: sys.exit( f"Widget {args.widgetId} is a {widget.get('type')!r} widget, not {widgetType!r} - refusing to " "change its font. Pass the id of a BloX widget instead." ) candidates = [ { "dashboardId": args.dashboardId, "dashboardTitle": "", "widgetId": args.widgetId, "widgetTitle": widget.get("title", ""), } ] logger.info(f"Targeting widget {args.widgetId} ({widget.get('title', '')!r}) directly - skipping the tenant scan.") else: dashboardHelper = Dashboard(api_client=apiClient) dashboards = dashboardHelper.get_all_dashboards() # On failure (unreachable domain, bad token, server error), pysisense # returns {"error": "..."} instead of a list - catch that here so it # surfaces as a clear message instead of a confusing crash further down. if not isinstance(dashboards, list): detail = dashboards.get("error", dashboards) if isinstance(dashboards, dict) else dashboards sys.exit( f"Could not retrieve dashboards from Sisense: {detail}\n\n" "Check that domain and token in config.yaml are correct, and that the server is reachable." ) scopeDescription = "every" if maxWidgets is None else f"up to {maxWidgets}" logger.info(f"Scanning {len(dashboards)} dashboard(s) for {scopeDescription} '{widgetType}' widget(s)...") candidates = findTargetWidgets(apiClient, dashboards, widgetType, isAdmin, maxWidgets, logger) logger.info(f"Found {len(candidates)} candidate widget(s).") # With args.apply False, this only previews - see applyChanges(). appliedCount, skippedCount = applyChanges( apiClient, candidates, selector, fontFamily, fontMode, args.apply, args.force, args.backupFile, isAdmin, args.quiet, args.verbose, logger, useTemporaryOwnership, apiUserId, ) logger.info(f"Done. Applied: {appliedCount}, skipped (already applied): {skippedCount}, candidates seen: {len(candidates)}.") # Surface the exact undo command after a real run, so it doesn't have to # be looked up in --help right when it might be needed. if not args.apply: logger.info("This was a dry run - nothing was written. Re-run with --apply to write changes.") elif appliedCount: logger.info(f"To revert everything just written, run: python {os.path.basename(__file__)} --undo --backup-file {args.backupFile}") if __name__ == "__main__": try: main() except KeyboardInterrupt: sys.exit("\nStopped.")

      Two Blox Widget's with different font families, and font colors
      Disclaimer: This post outlines a potential custom workaround for a specific use case or provides instructions regarding a specific task. The solution may not work in all scenarios or Sisense versions, so we strongly recommend testing it in your environment before deployment. If you need further assistance with this, please let us know.

      Jeremy Friedel
      Jeremy FriedelPosted 1 week ago
      0
               
    • Blog banner
      • Embedding AnalyticsChevronRightIcon

      From Zero to ComposeSDK: Building a Sisense React Application with an AI Coding Assistant

                                                                                                                                       

      From Zero to ComposeSDK: Building a Sisense React Application with an AI Coding Assistant A computer with no development related setup, no code editor application, no programming languages or runtimes such as Node installed, no development tooling of any kind, can create a running ComposeSDK React application in a single development session, connected to a live Sisense instance and ready for continued iteration. The computer can be a laptop, desktop, or server, and the finished app can be served from localhost, a local IP address, or a domain. Almost none of this development and setup has to be done by hand. An AI coding assistant installs the tooling, runs the commands, and writes the code, while the developer describes what should happen and confirms the results along the way. This style of development is sometimes called vibe coding, which refers to working conversationally and letting the AI handle the mechanics. Any modern LLM based coding assistant can drive this workflow. It needs two capabilities, editing code files in the project folder and running terminal commands. This article uses Claude Code in VS Code as the concrete example, but the same steps work in other editors with agentic assistants, in a standalone CLI tool like Claude CLI, or in a desktop app with terminal access like Claude Desktop. The prompts translate directly and are not specific to Claude. Only two steps require manual installation. The first is installing the text editor with its AI assistant, and the second is generating an API token inside the Sisense web interface. Everything else happens through conversation, from installing Node.js to creating the project, configuring credentials, generating the .ts representation of a Sisense data model, and building the widgets, dashboards, and other visualizations themselves. The starting point on the Sisense side is a modern Linux instance with at least one data model, either an ElastiCube or a live model, and permission to generate an API bearer token on it. Prerequisites A modern Linux Sisense instance with at least one data model Permission to generate an API bearer token on that instance A computer with the ability to install programs and CLI access Access to an LLM like Claude, or similar Getting Started An editor and an AI assistant have to be installed before anything can be done conversationally, so the first two steps are done manually. Download and install Visual Studio Code . Open the Extensions view in VS Code, search for the assistant of choice, install it, and sign in. For Claude Code, see the Claude Code documentation . Other agentic assistants with file editing and terminal access work the same way. One behavior is worth knowing about before the first prompt. Agentic assistants ask for approval before running each terminal command, so expect to click Yes regularly during setup, and read each command before approving it. The approval step exists so the developer always knows what is about to run on the machine, and it only provides that protection if the commands are actually read. Most assistants also offer an option to allow certain commands without asking again, which becomes convenient once a pattern has been reviewed a few times. Editing files inside the project folder is a sensible thing to approve universally. Commands that reach outside the project folder, or that install programs system wide, should be reviewed every time and not auto approved. How Granular to Be The sections below proceed step by step, with each prompt doing one focused thing. That structure is deliberate. It makes each step verifiable before the next is added, and it gives the reader a chance to learn what is happening at every stage. It is not a limitation of the LLM. In practice, an agentic assistant handles much larger requests comfortably. Given an empty folder and little else, a single prompt can carry the project most of the way. Set up a new ComposeSDK React application in this folder. Install Node if it is missing, create a React TypeScript Vite project, add the Sisense ComposeSDK packages, and start the dev server. My Sisense instance is at https://example.sisense.com and I will provide an API token in the file where it is needed, tell me where it is. The assistant works through the chain on its own, installing what is missing, flagging where the token goes, and reporting back when the dev server is running. Granular prompts tend to be the better choice while learning. Broad prompts work better once the process is familiar. Most real sessions mix both freely. If any errors are visible, pasting the error message into the LLM chat will often allow the LLM to resolve the issue. Checking the browser developer tools console for error messages to copy can also be helpful. Handing the Environment to the Assistant From this point forward, work happens in the assistant's chat panel. The first task is the development environment itself. To open the Claude VS Code UI, click the orange flower icon in the top right corner when a text file is open in VS Code. Open VS Code in a new empty folder that will hold the project (File, Open Folder, then create or select a directory. Name the folder appropriately, to match the name of the ComposeSDK (CSDK) application). Create an empty text file if the Claude VS Code icon (or the equivalent for the LLM in use) is hidden. Then open the LLM chat panel and ask the assistant to set up the toolchain. Install the latest LTS version of Node on this machine, make sure it is on the path, then verify that node and npm both work from the terminal. The assistant detects the operating system, picks an appropriate install method for that platform, runs it, and confirms the versions. If the path changed during installation, the assistant will usually mention that the terminal or VS Code may need a restart before the change takes effect. Node.js can also be installed manually for the relevant OS from the Node.js website download section , if the LLM struggles to install it via CLI. This first exchange establishes the pattern for everything that follows. State the goal, let the LLM assistant run the commands, confirm the result, move to the next step. Creating the Project With the toolchain verified, the next request creates the application. Create a new React TypeScript app in this folder using Vite, install the dependencies, and start the dev server so I can confirm the default page loads. The assistant plans and sets up the project, installs packages, and starts the Vite dev server, usually at http://localhost:5173 . Open that URL in a browser and confirm the default Vite page renders. Confirming each layer before adding the next is a habit worth keeping. If a later step fails, a verified baseline makes it obvious where the problem was introduced. Installing ComposeSDK ComposeSDK ships as npm packages, so adding it is one request. Add the Sisense ComposeSDK packages to this project: sdk-ui and sdk-data as dependencies, and the SDK CLI as a dev dependency. The three packages serve distinct purposes. @sisense/sdk-ui provides the React components, the charts, dashboards, and filter UI. @sisense/sdk-data provides the query construction layer, including dimensions, measures, and filter factories. @sisense/sdk-cli is a development tool used to generate the TypeScript data model files covered later in this article, and never ships with the application. Full ComposeSDK documentation is available on developer.sisense.com. Connecting to the Sisense Instance Two values connect and authenticate the CSDK application to Sisense, the instance URL and an API bearer token. Generate the token in the Sisense web interface, from the user profile menu under API Token ( Sisense documentation on generating API tokens ). Copy it once generated. The recommended approach keeps the token out of the LLM chat entirely, so the LLM never has access to it. The assistant builds the configuration with the token left blank. Create a .env file with VITE_SISENSE_URL set to https://example.sisense.com and VITE_SISENSE_TOKEN left blank. Add .env to .gitignore, deny your own read access to .env in your permission settings, wrap the app in SisenseContextProvider using those environment variables, and tell me where to paste my token. The assistant creates the .env file with the relevant keys, excludes it from both version control and its own file access, and points to where the token goes. Editing the URL and pasting the token into .env directly in the editor completes the setup. From that point the application can read the bearer token value but the assistant cannot. In Claude Code specifically, the mechanism for blocking its own access is a permission deny rule rather than an ignore file. The rule lives in .claude/settings.json at the project level, or in ~/.claude/settings.json to apply across all projects. { "permissions": { "deny": ["Read(./.env)", "Read(./.env.*)"] } } The /permissions command in Claude Code can also add this rule interactively, saving it to .claude/settings.local.json . Other assistants have their own equivalents, whether ignore files or access controls. Whichever tool is in use, it is worth verifying the block before pasting the real token. Put a dummy value in .env , ask the assistant to read the file, and confirm it refuses. Pasting the token straight into the chat and letting the assistant write the whole file also works, but is not recommended, since it means trusting the chat tool with a credential. Either way, keep .env out of version control and rotate the token if exposure is suspected. Clicking the refresh icon in the Profile bearer token page rotates the token. A token from a viewer role user is all that is required for CSDK, as long as that user has access to every dashboard and data source the application uses. Bearer tokens are also not the only option. ComposeSDK can authenticate with Web Access Tokens (WAT) and SSO as well, which are worth considering beyond initial development ( ComposeSDK authentication documentation ). The resulting setup looks approximately like this in main.tsx , the entry file Vite generates. Wrapping inside App.tsx works just as well, as long as the provider sits above every ComposeSDK component. import { createRoot } from 'react-dom/client'; import { SisenseContextProvider } from '@sisense/sdk-ui'; import App from './App.tsx'; // The provider supplies the connection to every ComposeSDK component below it. // Values come from .env so credentials stay out of source control. createRoot(document.getElementById('root')!).render( <SisenseContextProvider url={import.meta.env.VITE_SISENSE_URL} token={import.meta.env.VITE_SISENSE_TOKEN} > <App /> </SisenseContextProvider> ); Smoke Testing the Connection Before building further, it is worth confirming the application can reach and authenticate with the Sisense instance. There are two reasonable ways to do it. The fastest is DashboardById , which renders an existing Sisense dashboard and requires no data model work in the project. Open any dashboard in Sisense and copy its OID from the browser URL. Render the dashboard with OID 65a1b2c3d4e5f6a7b8c9d0e1 from my Sisense instance on the main page using DashboardById, replacing the default Vite content. The alternative is a minimal chart against a known data model, which previews the widget workflow used in the rest of this article. Either one confirms the same thing, that the URL and token work and the instance is reachable from the browser. A rendered dashboard means the connection is established. A blank or broken one usually traces back to one of these common causes. The token is invalid or belongs to a user without access to the dashboard or data source. Regenerate the token and confirm the user can open the dashboard in Sisense directly. The URL is incorrect, commonly a missing https:// or a trailing path. The value should be the bare instance origin. The browser blocks the request with a CORS error. ComposeSDK typically works without any CORS configuration, but if the console shows a CORS error, an administrator can add http://localhost:5173 to the allowed origins in the Sisense Admin section under Security Settings ( Sisense documentation on CORS configuration ). Browser console errors generally identify which of these applies, and the error output can be pasted straight into the chat for diagnosis. Generating the TypeScript Data Model ComposeSDK includes a built in tool for this step. The SDK CLI installed earlier generates a .ts file that describes the data model, listing its tables, dimensions, and measures as typed TypeScript objects, so widget code references real fields with autocomplete rather than hand typed strings. It works the same way whether the model is an ElastiCube or a live model. Being specific in the prompt matters here. Naming the goal, a .ts file generated by the SDK's own CLI, steers the assistant toward the right tool, there otherwise being the possibility it would try to create or hallucinate its own representation. Use the ComposeSDK CLI (the get-data-model command from @sisense/sdk-cli) to generate a .ts file describing my data model named Sample ECommerce, and save it to src/models. Use the token in .env. Under the hood this runs a command of the following form. # Generates a .ts file describing the data model's tables, dimensions, and measures. # The token and URL are the same values used by the app itself. npx @sisense/sdk-cli get-data-model \ --url https://example.sisense.com \ --token <token> \ --dataSource "Sample ECommerce" \ --output src/models/sample-ecommerce.ts The generated .ts file exports a typed object, conventionally imported as DM , containing every table, dimension, and measure in the model. Everything built from this point on references fields through that object, which means typos become compile errors instead of silently empty charts. Building the First Widget With the .ts model file in place, building the first real chart is a single request. Using the generated Sample ECommerce data model in src/models, add a column chart below the dashboard showing total Revenue by Condition. The assistant writes a component along these lines. import { Chart } from '@sisense/sdk-ui'; import { measureFactory } from '@sisense/sdk-data'; import * as DM from './models/sample-ecommerce'; // A minimal ComposeSDK chart: one category dimension, one aggregated measure. <Chart dataSet={DM.DataSource} chartType="column" dataOptions={{ category: [DM.Commerce.Condition], value: [measureFactory.sum(DM.Commerce.Revenue, 'Total Revenue')], }} /> Once it renders, iteration can be done through continued conversation or manual code editing. The Vite application will auto update with any changes, without requiring manual refreshes. Change it to a bar chart, break it down by Gender, and only include 2024 data. The assistant adds the breakBy entry to the data options and a date filter to the chart, and the result appears on the next hot reload. Describe a change, see it render, refine. That loop is the core of the workflow, and it is the same whether the change is a chart type, a filter, or a layout adjustment. Styling and Structuring the Application The same conversational approach turns the result from a test page into something that looks like an application. Add a professional looking header and footer to the application, including the title [app title], and lay out the dashboard and the chart in cards. What this prompt actually says will vary by user and project. The card layout here is just one option, and the assistant follows whatever visual direction it is given, from minimal to fully branded. One distinction is worth knowing. Application CSS and widget theming are separate layers. Page layout, headers, and footers are ordinary React and CSS. The appearance of the ComposeSDK widgets themselves, including chart colors, fonts, and backgrounds, is controlled through the SDK's ThemeProvider component ( ComposeSDK theming documentation ). Asking for "a dark theme on the charts" versus "a dark background on the page" will, correctly, touch different code. Working with Live Data The ComposeSDK application itself does not typically contain or store data, though exceptions exist, a point that is often misunderstood. Every widget sends a JAQL query to the Sisense instance at render time, so the CSDK application always reflects the current state of the data model. For an ElastiCube that means fresh data appears after each build, and for a live model queries hit the source directly. In neither case does updated data require rebuilding or redeploying the application. The generated .ts model file only needs regeneration when the schema changes, such as new fields, renamed columns, or restructured tables. Data refreshes do not affect it. When the schema does change, regeneration is one request. My data model schema changed. Use the ComposeSDK CLI to regenerate the .ts model file and tell me if any existing widget code references fields that no longer exist. Because the model file is typed, removed fields surface as compile errors, and the assistant can identify and fix the affected components in the same exchange. Prompt Library The prompts below are starting points, not strings that need to be copied exactly. Everything in this article was done conversationally, and describing the goal in natural language as it comes to mind is usually faster than locating a prompt to paste. What makes these prompts work is their specificity, with actual field names, file locations, and a clear definition of when the task is finished. Those qualities carry over to any phrasing, including a single broad prompt that covers several steps at once. Setup and environment: Install the latest LTS version of Node, make sure it is on the path, and verify node and npm from the terminal. Create a React TypeScript Vite app here, install dependencies, and start the dev server. Set up a complete ComposeSDK React app in this folder: install anything missing, create the project, add the SDK packages, and get the dev server running. ComposeSDK installation and connection: Add the ComposeSDK packages: sdk-ui and sdk-data as dependencies and the SDK CLI as a dev dependency. Create a .env with my Sisense URL and a blank token, add it to .gitignore, deny your own read access to it, wire up SisenseContextProvider from those variables, and tell me where to paste my token. The dashboard component shows a network error. Here is the browser console output: [paste]. Diagnose whether this is CORS, the token, the URL, or something else. Data and widgets: Use the ComposeSDK CLI to generate a .ts file for the data model named [name] into src/models. Render the dashboard with OID [oid] using DashboardById. Add a line chart of [measure] by [date dimension] at month granularity, with a member filter on [dimension] limited to [values]. Add breakBy on [dimension] to the existing column chart and move the legend to the bottom. Advanced: Use useExecuteQuery to fetch [measure] grouped by [dimension] and render the result in a plain HTML table instead of an SDK chart component. Use onBeforeRender on the line chart to set the line width to 3 and enable data labels through the underlying Highcharts options. The advanced prompts reference real ComposeSDK extension points. useExecuteQuery runs a query directly and returns rows for custom rendering, and onBeforeRender exposes the underlying Highcharts options object before a chart draws, allowing customization beyond the SDK's own props ( useExecuteQuery reference , chart customization reference ). Useful Links ComposeSDK documentation ComposeSDK quickstart for React Claude Code documentation Sisense REST API and authentication documentation The techniques in this article generalize. Any capability in the ComposeSDK documentation, from dashboard filters to custom widget types, is reachable the same way. Describe it to the assistant with enough specificity, confirm the result in the browser, and refine conversationally.

      Jeremy Friedel
      Jeremy FriedelPosted 1 month ago • Last reply Jun 17, 2026 at 9:52 PM
      3
               
      • APIsChevronRightIcon

      How to Export a List of Sisense Users

               

      Question : How can I export a list of Sisense users, for example users from a specific group?   Solution : Using the REST API you can pretty quickly get this info in JSON document format. The GET /users API supports group ID:  You could run GET /groups with the group name to get it's ID:   Then use that group ID in the GET /users call which will provide users who are members of that group. You can even use the 'fields' parameter to include or exclude fields.   If you use the "v1" API instead, which is at /api/v1/users, you could simplify the results from this API by specifying which fields should be returned, via the "fields" parameter: or as it would be in the URL: http://10.50.12.161:30845/api/v1/users?fields=userName%2Cemail Other solution is to connect to Sisense MongoDB through Mongo Connector https://documentation.sisense.com/latest/managing-data/connectors/mongodb_online.htm#gsc.tab=0 , before that you need to setup read user on your mongo for Sisense via this API: After you setup this user and connect using it to Mongo on Sisense you will be able to select connecting to some databases and you should select this one: The in Collections look for Users and Groups tables. Due to JSON structure mongo connector can generate multiple tables for Users and Groups depending on how many nested JSON structures are within it. That should be relatively easy to go through, and then you should have a full structure of Users per Group. What is more, this can be imported inside Sisense Elasticube so you will be able to create a dashboard based on this and export it into any format.   

      intapiuser
      intapiuserPosted 3 years ago • Last reply 2 years ago
      1
               
      • How-Tos & FAQsChevronRightIcon

      How To Find Your Sisense Version? (Windows only)

                               

      Question : How can I find the exact version and build of my Sisense environment?   Solution : There are 3 easy ways to find the exact version of your environment:   On the web application Open your Sisense web application On the top toolbar, click on the person icon. The version will be the last item on the menu which pops up: On the Desktop ECM Note: Not relevant for Sisense Linux Open the desktop Elasticube Manager Click the Help & How-To's button on the top scroll bar Click the About button The version will be here:   On the web application Open dev tools - console (F12) Copy this and press enter: prism.version The version will be displayed as output:

      intapiuser
      intapiuserPosted 3 years ago • Last reply 2 years ago
      1
               
      • How-Tos & FAQsChevronRightIcon

      Check whether running Sisense on Linux or Windows

               

      Some steps in the Sisense support documentation vary depending on whether the Sisense instance you are working with is running on Windows or Linux. These steps are how you can check: Log into your Sisense instance Click on the person icon in the top right hand corner See what the "Version" field says in the resulting card Example 1: Below is an example of a Linux environment - the "L" at the start of the version indicates it is a Linux instance. Example 2: Below is an example of a Windows environment - Windows environments starting in 2021 will start with a "20." at the start of the version. We know it is a Windows environment because it does not have an "L" at the start of the version. Example 3: Below is an example of a Windows environment prior to 2021. We know it is a Windows environment because it does not have an "L" at the start of the version.

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • APIsChevronRightIcon

      Installing Python On IIS

                       

      Question : Some users might want to install python on their IIS, for example in order to establish SSO by using python code. How can I install it?   Solution : In order to do so, follow these steps: Insure you have CGI installed on your IIS:  Go to Start -> Control Panel -> Programs and Features -> on the left hand side, go to "Turn Windows Features on or off" -> Under the IIS node, make sure CGI is installed: Download Python for Windows , from python.org (doesn't matter 2.7 or python 3, depends on your preference). Make sure you get the x64 version if you have an x64 version of Windows. Unpack and install that python MSI . Choose the default, which puts python into  c:\PythonX (X will be replaced with your Python version) Create a directory to hold your "development" python scripts . Eg,  c:\dev\python Set the permissions on the files in the directory  c:\dev\python  to allow IIS to read and execute. Do this by running these two icacls.exe commands from the command line: cd \dev\python icacls . /grant "NT AUTHORITY\IUSR:(OI)(CI)(RX)" icacls . /grant "Builtin\IIS_IUSRS:(OI)(CI)(RX)" Open the IIS manager, and create a new application.  Specify the virtual path as  /py  and the physical path as  c:\dev\python: Within the new IIS application, add a script map for  *.py , and map it to  c:\python27\python.exe %s %s: create a "HelloWorld.py" file in  c:\dev\python  with this as the content: print('Content-Type: text/plain') print('') print('Hello, world!') Test that the script is working - invoke in the browser  http://localhost/py/helloworld.py   * You should be able to use all Python libraries you have installed on your system. *In case that you try to open the script and it returns a 502 error, it means that something is wrong in the script. In such cases, debug it on your IDLE instead through the browser    

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • TroubleshootingChevronRightIcon

      [Custom SMTP Email Server] - Outlook365 Error `432 4.3.2 STOREDRV.ClientSubmit; Sender Thread Limit Exceeded`

                       

      Question : As of version 7.3, If you're using Outlook365 (not on-prem) as your SMTP server, you might encounter the following error when sending batch emails: 432 4.3.2 STOREDRV.ClientSubmit; sender thread limit exceeded Essentially, this error is thrown back from the SMTP server telling us that we've opened too many simultaneous connections which exceeds the limit (3) of the server.   Solution : The reporting engine utilizes nodeMailer to send out emails. Its transport is configurable so what we'd need to do is limit the number of connections it opens and utilize a pooled connection.   To change these settings: 1) Create a backup of the file  C:\Program Files\Sisense\app\galaxy-service\src\features\emails\v1\mailSender.js ​   2) Open the original and modify the following object:   function getSmtpTransport( ){ ... return mailer.createTransport( {   maxConnections: 3, //<-----------ADD THIS LINE   pool: true,        //<-----------ADD THIS LINE   host: emailServerConfig.host || 'localhost',   port: emailServerConfig.port || 465,   secure: emailServerConfig.secure || false,   ignoreTLS: emailServerConfig.ignoreTLS || false,   requireTLS: emailServerConfig.requireTLS || false,   connectionTimeout: emailServerConfig.connectionTimeout || 10000,   greetingTimeout: emailServerConfig.greetingTimeout || 5000,   socketTimeout: emailServerConfig.socketTimeout || 5000,   logger: emailServerConfig.logger || false,   debug: emailServerConfig.debug || false,   auth: auth    } ); } 3) Restart Sisense.Galaxy service.   If that does not help you please create a support ticket and our support agents would be happy to help.   Sources : https://nodemailer.com/smtp/pooled/   https://support.microsoft.com/en-us/help/4458479/improvements-in-smtp-authenticated-submission-client-protocol  

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • TroubleshootingChevronRightIcon

      MongoDB logs take too much Drive space

                       

      Question : MongoDB logs keep growing and lead to no available space on Drive.   Solution : Need to Enable Log Rotation In MongoDB. The following properties, located in "C:\Program Files\Sisense\Infra\MongoDB\mongodbconfig.conf", enable the log rotation and stop the continuous log growth: If that does not help you please create a support ticket and our support agents would be happy to help.

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • TroubleshootingChevronRightIcon

      Force Start A Sisense Service From Windows PowerShell

                               

      Question : Sisense Service hangs and I try to start them in Windows Services, but the usual  net stop & net start  will not restart them.  net stop /y  will stop all dependencies.     Solution : Follow the instructions below to force start a Sisense Service: Log on to the server with your administrator privileges. Locate PowerShell and run it as administrator (right click -> Run as administrator). Copy and run the following commands: powershell -command "Restart-Service Sisense.ClrConnectorsContainer -Force"

      intapiuser
      intapiuserPosted 3 years ago
      0