Initial commit
This commit is contained in:
commit
00392d28e3
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
201
LICENSE
Normal file
201
LICENSE
Normal file
@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
BIN
MSNET/Microsoft.AnalysisServices.AdomdClient.dll
Normal file
BIN
MSNET/Microsoft.AnalysisServices.AdomdClient.dll
Normal file
Binary file not shown.
80
README.md
Normal file
80
README.md
Normal file
@ -0,0 +1,80 @@
|
||||
# Power BI Measure Call Graph
|
||||
|
||||
A Flask web application that visualizes the dependency relationships between measures in a Power BI / Analysis Services model.
|
||||
|
||||
## Features
|
||||
|
||||
- Connect to Analysis Services models
|
||||
- Extract measures and their DAX expressions
|
||||
- Parse DAX expressions to determine dependencies between measures
|
||||
- Visualize the call graph with an interactive D3.js visualization
|
||||
- Search for specific measures
|
||||
- Click on measures to highlight dependencies
|
||||
- Hover over measures to see their DAX expressions
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.7+
|
||||
- Microsoft Analysis Services ODBC driver (MSOLAP)
|
||||
- Access to a Power BI / Analysis Services model
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone this repository:
|
||||
```
|
||||
git clone https://github.com/yourusername/pbi-measure-callgraph.git
|
||||
cd pbi-measure-callgraph
|
||||
```
|
||||
|
||||
2. Create a virtual environment (optional but recommended):
|
||||
```
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
source venv/bin/activate # macOS/Linux
|
||||
```
|
||||
|
||||
3. Install the required packages:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Start the Flask application:
|
||||
```
|
||||
python app.py
|
||||
```
|
||||
|
||||
2. Open your web browser and navigate to:
|
||||
```
|
||||
http://localhost:5000
|
||||
```
|
||||
|
||||
3. Enter your Analysis Services server and database information and click "Connect"
|
||||
|
||||
4. Once connected, the call graph will be displayed with all measures from your model
|
||||
|
||||
5. Interact with the graph:
|
||||
- Click on a measure to highlight its dependencies
|
||||
- Hover over a measure to see its DAX expression
|
||||
- Use the search box to find specific measures
|
||||
- Use the mouse wheel to zoom in/out
|
||||
- Drag the graph to pan
|
||||
- Click the "Reset View" button to center the graph
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Connection**: The application connects to your Analysis Services model using the MSOLAP ODBC driver
|
||||
2. **Measure Extraction**: It retrieves all visible measures and their DAX expressions
|
||||
3. **Dependency Analysis**: The DAX parser analyzes each expression to identify references to other measures
|
||||
4. **Visualization**: The D3.js force-directed graph visualizes the dependencies between measures
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Connection Issues**: Ensure you have the MSOLAP driver installed and that you have access to the specified server and database
|
||||
- **Missing Measures**: Only visible measures are included in the graph
|
||||
- **Incorrect Dependencies**: The DAX parser uses a simplified approach to identify measure references and may not catch all complex scenarios
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
BIN
__pycache__/dax_parser.cpython-313.pyc
Normal file
BIN
__pycache__/dax_parser.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/model_connector.cpython-313.pyc
Normal file
BIN
__pycache__/model_connector.cpython-313.pyc
Normal file
Binary file not shown.
61
app.py
Normal file
61
app.py
Normal file
@ -0,0 +1,61 @@
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
import json
|
||||
import os
|
||||
from model_connector import ModelConnector
|
||||
from dax_parser import DaxParser
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Initialize the model connector and DAX parser
|
||||
model_connector = None
|
||||
dax_parser = DaxParser()
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Render the main page."""
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/connect', methods=['POST'])
|
||||
def connect():
|
||||
"""Connect to the Analysis Services model."""
|
||||
global model_connector
|
||||
|
||||
data = request.json
|
||||
server = data.get('server')
|
||||
database = data.get('database')
|
||||
|
||||
try:
|
||||
model_connector = ModelConnector(server, database)
|
||||
return jsonify({'success': True, 'message': 'Connected successfully'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@app.route('/get_measures')
|
||||
def get_measures():
|
||||
"""Get all measures from the connected model."""
|
||||
if not model_connector:
|
||||
return jsonify({'success': False, 'message': 'Not connected to a model'})
|
||||
|
||||
try:
|
||||
measures = model_connector.get_measures()
|
||||
return jsonify({'success': True, 'measures': measures})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@app.route('/get_call_graph')
|
||||
def get_call_graph():
|
||||
"""Generate and return the call graph data."""
|
||||
if not model_connector:
|
||||
return jsonify({'success': False, 'message': 'Not connected to a model'})
|
||||
|
||||
try:
|
||||
measures_df = model_connector.get_measures()
|
||||
measures = measures_df.to_dict(orient="records")
|
||||
graph = dax_parser.build_call_graph(measures)
|
||||
return jsonify({'success': True, 'graph': graph})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
|
70
dax_parser.py
Normal file
70
dax_parser.py
Normal file
@ -0,0 +1,70 @@
|
||||
import re
|
||||
|
||||
class DaxParser:
|
||||
"""Class to parse DAX expressions and build dependency graphs."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the DAX parser."""
|
||||
# Regular expression to match measure references in DAX
|
||||
# This is a simplified pattern and may need refinement for complex DAX
|
||||
self.measure_ref_pattern = r'\[([^\]]+)\]'
|
||||
|
||||
def extract_measure_references(self, dax_expression, all_measure_names):
|
||||
"""
|
||||
Extract references to other measures from a DAX expression.
|
||||
|
||||
Args:
|
||||
dax_expression (str): The DAX expression to parse
|
||||
all_measure_names (list): List of all measure names in the model
|
||||
|
||||
Returns:
|
||||
list: Names of measures referenced in the expression
|
||||
"""
|
||||
if not dax_expression:
|
||||
return []
|
||||
|
||||
# Find all potential measure references (anything in square brackets)
|
||||
potential_refs = re.findall(self.measure_ref_pattern, dax_expression)
|
||||
|
||||
# Filter to only include actual measure names
|
||||
# This helps avoid false positives like column references
|
||||
measure_refs = [ref for ref in potential_refs if ref in all_measure_names]
|
||||
|
||||
return list(set(measure_refs)) # Remove duplicates
|
||||
|
||||
def build_call_graph(self, measures):
|
||||
"""
|
||||
Build a call graph of measure dependencies.
|
||||
|
||||
Args:
|
||||
measures (list): List of measure dictionaries with 'name' and 'expression' keys
|
||||
|
||||
Returns:
|
||||
dict: A graph representation with nodes and links
|
||||
"""
|
||||
# Extract all measure names
|
||||
all_measure_names = [measure['name'] for measure in measures]
|
||||
|
||||
# Create nodes for the graph
|
||||
nodes = [{'id': measure['name'], 'expression': measure['expression']} for measure in measures]
|
||||
|
||||
# Create links (dependencies) for the graph
|
||||
links = []
|
||||
for measure in measures:
|
||||
source = measure['name']
|
||||
expression = measure['expression']
|
||||
|
||||
# Find references to other measures in this expression
|
||||
references = self.extract_measure_references(expression, all_measure_names)
|
||||
|
||||
# Add links for each reference
|
||||
for target in references:
|
||||
links.append({
|
||||
'source': source,
|
||||
'target': target
|
||||
})
|
||||
|
||||
return {
|
||||
'nodes': nodes,
|
||||
'links': links
|
||||
}
|
89
model_connector.py
Normal file
89
model_connector.py
Normal file
@ -0,0 +1,89 @@
|
||||
from sys import path
|
||||
path.append('D:\\Personal_Files\\document\\GitHub\\Test\\MSNET')
|
||||
|
||||
import pyadomd
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class ModelConnector:
|
||||
"""Class to connect to and retrieve data from Analysis Services models."""
|
||||
|
||||
def __init__(self, server, database):
|
||||
"""
|
||||
Initialize the connection to the Analysis Services model.
|
||||
|
||||
Args:
|
||||
server (str): The server name or IP address
|
||||
database (str): The database name
|
||||
"""
|
||||
self.server = server
|
||||
self.database = database
|
||||
# self.connection_string = f"Provider=MSOLAP;Data Source={server}"
|
||||
self.connection_string = f"Provider=MSOLAP;Data Source=localhost:56195"
|
||||
self.connection = None
|
||||
self.connect()
|
||||
|
||||
def connect(self):
|
||||
"""Establish a connection to the Analysis Services model."""
|
||||
try:
|
||||
self.connection = pyadomd.Pyadomd(self.connection_string)
|
||||
self.connection.open()
|
||||
return True
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to connect to the model: {str(e)}")
|
||||
|
||||
def execute_query(self, query):
|
||||
"""
|
||||
Execute a DAX or MDX query against the model.
|
||||
|
||||
Args:
|
||||
query (str): The DAX or MDX query to execute
|
||||
|
||||
Returns:
|
||||
pandas.DataFrame: The query results
|
||||
"""
|
||||
if not self.connection:
|
||||
self.connect()
|
||||
|
||||
try:
|
||||
with self.connection.cursor().execute(query) as conn:
|
||||
rows = conn.fetchall()
|
||||
df = pd.DataFrame(rows, columns=[col[0] for col in conn._description])
|
||||
return df
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to execute query: {str(e)}")
|
||||
|
||||
def get_measures(self):
|
||||
"""
|
||||
Retrieve all measures from the model.
|
||||
|
||||
Returns:
|
||||
list: A list of dictionaries containing measure information
|
||||
"""
|
||||
# DMV query to get all measures
|
||||
query = """
|
||||
SELECT
|
||||
[MEASURE_NAME] as [name],
|
||||
[EXPRESSION] as [expression],
|
||||
[MEASURE_CAPTION] as [caption],
|
||||
[MEASURE_DISPLAY_FOLDER] as [display_folder],
|
||||
[DESCRIPTION] as [description]
|
||||
FROM $SYSTEM.MDSCHEMA_MEASURES
|
||||
WHERE MEASURE_IS_VISIBLE
|
||||
"""
|
||||
|
||||
try:
|
||||
df = self.execute_query(query)
|
||||
return df if not df.empty else []
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to retrieve measures: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Example usage
|
||||
server = 'localhost:56195'
|
||||
database = 'Exteranl All Channel'
|
||||
|
||||
model_connector = ModelConnector(server, database)
|
||||
measures = model_connector.get_measures()
|
||||
print(measures) # List of measure dictionaries
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
Flask==2.3.3
|
||||
pyodbc==4.0.39
|
||||
pandas==2.2.3
|
188
static/css/style.css
Normal file
188
static/css/style.css
Normal file
@ -0,0 +1,188 @@
|
||||
/* Global styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #0078d4;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #0078d4;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Connection panel */
|
||||
.connection-panel {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #0078d4;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #005a9e;
|
||||
}
|
||||
|
||||
#connection-status {
|
||||
margin-top: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #107c10;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d83b01;
|
||||
}
|
||||
|
||||
/* Visualization container */
|
||||
.visualization-container {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex-grow: 1;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
#graph-container {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Graph styles */
|
||||
.node circle {
|
||||
fill: #0078d4;
|
||||
stroke: #fff;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.node text {
|
||||
font-size: 12px;
|
||||
fill: #333;
|
||||
}
|
||||
|
||||
.node.highlighted circle {
|
||||
fill: #ffb900;
|
||||
}
|
||||
|
||||
.node.selected circle {
|
||||
fill: #107c10;
|
||||
}
|
||||
|
||||
.link {
|
||||
stroke: #999;
|
||||
stroke-opacity: 0.6;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.link.highlighted {
|
||||
stroke: #ffb900;
|
||||
stroke-opacity: 0.8;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
/* Measure details */
|
||||
.measure-details {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#measure-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#measure-expression {
|
||||
background-color: #f8f8f8;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
white-space: pre-wrap;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
max-width: 300px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
121
static/js/app.js
Normal file
121
static/js/app.js
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Main application JavaScript for the Power BI Measure Call Graph
|
||||
*/
|
||||
|
||||
// DOM elements
|
||||
const serverInput = document.getElementById('server');
|
||||
const databaseInput = document.getElementById('database');
|
||||
const connectBtn = document.getElementById('connect-btn');
|
||||
const connectionStatus = document.getElementById('connection-status');
|
||||
const resetBtn = document.getElementById('reset-btn');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const measureName = document.getElementById('measure-name');
|
||||
const measureExpression = document.getElementById('measure-expression');
|
||||
|
||||
// Graph instance
|
||||
let graph = null;
|
||||
|
||||
// Event listeners
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize the graph
|
||||
graph = new Graph('#graph-container');
|
||||
|
||||
// Connect button
|
||||
connectBtn.addEventListener('click', connectToModel);
|
||||
|
||||
// Reset button
|
||||
resetBtn.addEventListener('click', () => {
|
||||
if (graph) {
|
||||
graph.resetView();
|
||||
}
|
||||
});
|
||||
|
||||
// Search input
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
if (graph) {
|
||||
graph.searchNodes(e.target.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Connect to the Analysis Services model
|
||||
*/
|
||||
async function connectToModel() {
|
||||
const server = serverInput.value.trim() || 'localhost';
|
||||
const database = databaseInput.value.trim();
|
||||
|
||||
if (!database) {
|
||||
showConnectionStatus('Please enter a database name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showConnectionStatus('Connecting...', '');
|
||||
|
||||
// Connect to the model
|
||||
const connectResponse = await fetch('/connect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ server, database })
|
||||
});
|
||||
|
||||
const connectData = await connectResponse.json();
|
||||
|
||||
if (!connectData.success) {
|
||||
showConnectionStatus(`Connection failed: ${connectData.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showConnectionStatus('Connected successfully. Loading measures...', 'success');
|
||||
|
||||
// Get the call graph data
|
||||
const graphResponse = await fetch('/get_call_graph');
|
||||
const graphData = await graphResponse.json();
|
||||
|
||||
if (!graphData.success) {
|
||||
showConnectionStatus(`Failed to load graph: ${graphData.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize the graph with the data
|
||||
graph.setData(graphData.graph);
|
||||
graph.render();
|
||||
|
||||
showConnectionStatus('Graph loaded successfully', 'success');
|
||||
} catch (error) {
|
||||
showConnectionStatus(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show connection status message
|
||||
* @param {string} message - The message to display
|
||||
* @param {string} type - The message type (success, error, or empty for neutral)
|
||||
*/
|
||||
function showConnectionStatus(message, type) {
|
||||
connectionStatus.textContent = message;
|
||||
connectionStatus.className = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display measure details
|
||||
* @param {Object} measure - The measure object
|
||||
*/
|
||||
function showMeasureDetails(measure) {
|
||||
if (!measure) {
|
||||
measureName.textContent = '';
|
||||
measureExpression.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
measureName.textContent = measure.id;
|
||||
measureExpression.textContent = measure.expression || 'No expression available';
|
||||
}
|
||||
|
||||
// Export functions for use in graph.js
|
||||
window.appFunctions = {
|
||||
showMeasureDetails
|
||||
};
|
277
static/js/graph.js
Normal file
277
static/js/graph.js
Normal file
@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Graph visualization using D3.js for the Power BI Measure Call Graph
|
||||
*/
|
||||
|
||||
class Graph {
|
||||
/**
|
||||
* Initialize the graph
|
||||
* @param {string} selector - CSS selector for the container element
|
||||
*/
|
||||
constructor(selector) {
|
||||
this.container = d3.select(selector);
|
||||
this.width = this.container.node().getBoundingClientRect().width;
|
||||
this.height = this.container.node().getBoundingClientRect().height;
|
||||
this.data = null;
|
||||
this.simulation = null;
|
||||
this.svg = null;
|
||||
this.zoom = null;
|
||||
this.tooltip = null;
|
||||
this.selectedNode = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SVG and D3 components
|
||||
*/
|
||||
init() {
|
||||
// Create SVG
|
||||
this.svg = this.container.append('svg')
|
||||
.attr('width', this.width)
|
||||
.attr('height', this.height)
|
||||
.attr('class', 'graph-svg');
|
||||
|
||||
// Create zoom behavior
|
||||
this.zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
this.g.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
// Apply zoom to SVG
|
||||
this.svg.call(this.zoom);
|
||||
|
||||
// Create a group for the graph elements
|
||||
this.g = this.svg.append('g');
|
||||
|
||||
// Create tooltip
|
||||
this.tooltip = d3.select('body').append('div')
|
||||
.attr('class', 'tooltip')
|
||||
.style('opacity', 0);
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
this.width = this.container.node().getBoundingClientRect().width;
|
||||
this.height = this.container.node().getBoundingClientRect().height;
|
||||
this.svg.attr('width', this.width).attr('height', this.height);
|
||||
if (this.simulation) {
|
||||
this.simulation.alpha(0.3).restart();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the graph data
|
||||
* @param {Object} data - The graph data with nodes and links
|
||||
*/
|
||||
setData(data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the graph
|
||||
*/
|
||||
render() {
|
||||
if (!this.data) return;
|
||||
|
||||
// Clear previous graph
|
||||
this.g.selectAll('*').remove();
|
||||
|
||||
// Create links
|
||||
const links = this.g.append('g')
|
||||
.attr('class', 'links')
|
||||
.selectAll('line')
|
||||
.data(this.data.links)
|
||||
.enter().append('line')
|
||||
.attr('class', 'link');
|
||||
|
||||
// Create nodes
|
||||
const nodes = this.g.append('g')
|
||||
.attr('class', 'nodes')
|
||||
.selectAll('.node')
|
||||
.data(this.data.nodes)
|
||||
.enter().append('g')
|
||||
.attr('class', 'node')
|
||||
.call(d3.drag()
|
||||
.on('start', this.dragStarted.bind(this))
|
||||
.on('drag', this.dragged.bind(this))
|
||||
.on('end', this.dragEnded.bind(this)));
|
||||
|
||||
// Add circles to nodes
|
||||
nodes.append('circle')
|
||||
.attr('r', 8);
|
||||
|
||||
// Add text labels to nodes
|
||||
nodes.append('text')
|
||||
.attr('dx', 12)
|
||||
.attr('dy', '.35em')
|
||||
.text(d => d.id);
|
||||
|
||||
// Add event listeners to nodes
|
||||
nodes
|
||||
.on('mouseover', (event, d) => {
|
||||
// Show tooltip
|
||||
this.tooltip.transition()
|
||||
.duration(200)
|
||||
.style('opacity', .9);
|
||||
this.tooltip.html(d.expression)
|
||||
.style('left', (event.pageX + 10) + 'px')
|
||||
.style('top', (event.pageY - 28) + 'px');
|
||||
})
|
||||
.on('mouseout', () => {
|
||||
// Hide tooltip
|
||||
this.tooltip.transition()
|
||||
.duration(500)
|
||||
.style('opacity', 0);
|
||||
})
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.selectNode(d);
|
||||
});
|
||||
|
||||
// Create simulation
|
||||
this.simulation = d3.forceSimulation(this.data.nodes)
|
||||
.force('link', d3.forceLink(this.data.links).id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
||||
.force('collision', d3.forceCollide().radius(30))
|
||||
.on('tick', () => {
|
||||
links
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
|
||||
nodes.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
// Add click handler to SVG to deselect nodes
|
||||
this.svg.on('click', () => {
|
||||
this.selectNode(null);
|
||||
});
|
||||
|
||||
// Center the graph initially
|
||||
this.resetView();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag start event
|
||||
*/
|
||||
dragStarted(event, d) {
|
||||
if (!event.active) this.simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag event
|
||||
*/
|
||||
dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag end event
|
||||
*/
|
||||
dragEnded(event, d) {
|
||||
if (!event.active) this.simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a node and highlight its connections
|
||||
* @param {Object|null} node - The node to select, or null to deselect
|
||||
*/
|
||||
selectNode(node) {
|
||||
this.selectedNode = node;
|
||||
|
||||
// Show measure details
|
||||
if (window.appFunctions && window.appFunctions.showMeasureDetails) {
|
||||
window.appFunctions.showMeasureDetails(node);
|
||||
}
|
||||
|
||||
// Reset all nodes and links
|
||||
this.g.selectAll('.node').classed('selected', false).classed('highlighted', false);
|
||||
this.g.selectAll('.link').classed('highlighted', false);
|
||||
|
||||
if (!node) return;
|
||||
|
||||
// Get connected nodes
|
||||
const connectedNodes = new Set();
|
||||
const connectedLinks = new Set();
|
||||
|
||||
// Find incoming links (where this node is the target)
|
||||
this.data.links.forEach(link => {
|
||||
if (link.target.id === node.id) {
|
||||
connectedNodes.add(link.source.id);
|
||||
connectedLinks.add(link);
|
||||
}
|
||||
});
|
||||
|
||||
// Find outgoing links (where this node is the source)
|
||||
this.data.links.forEach(link => {
|
||||
if (link.source.id === node.id) {
|
||||
connectedNodes.add(link.target.id);
|
||||
connectedLinks.add(link);
|
||||
}
|
||||
});
|
||||
|
||||
// Highlight the selected node
|
||||
this.g.selectAll('.node')
|
||||
.filter(d => d.id === node.id)
|
||||
.classed('selected', true);
|
||||
|
||||
// Highlight connected nodes
|
||||
this.g.selectAll('.node')
|
||||
.filter(d => connectedNodes.has(d.id))
|
||||
.classed('highlighted', true);
|
||||
|
||||
// Highlight connected links
|
||||
this.g.selectAll('.link')
|
||||
.filter(d => connectedLinks.has(d))
|
||||
.classed('highlighted', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for nodes by name
|
||||
* @param {string} query - The search query
|
||||
*/
|
||||
searchNodes(query) {
|
||||
if (!query) {
|
||||
// Reset all nodes if query is empty
|
||||
this.g.selectAll('.node').style('opacity', 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// Filter nodes based on the query
|
||||
this.g.selectAll('.node').style('opacity', d => {
|
||||
return d.id.toLowerCase().includes(lowerQuery) ? 1 : 0.2;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the view to center the graph
|
||||
*/
|
||||
resetView() {
|
||||
const bounds = this.g.node().getBBox();
|
||||
const dx = bounds.width;
|
||||
const dy = bounds.height;
|
||||
const x = bounds.x + dx / 2;
|
||||
const y = bounds.y + dy / 2;
|
||||
|
||||
// Calculate the scale to fit the graph
|
||||
const scale = 0.9 / Math.max(dx / this.width, dy / this.height);
|
||||
const translate = [this.width / 2 - scale * x, this.height / 2 - scale * y];
|
||||
|
||||
// Apply the transform
|
||||
this.svg.transition()
|
||||
.duration(750)
|
||||
.call(this.zoom.transform, d3.zoomIdentity
|
||||
.translate(translate[0], translate[1])
|
||||
.scale(scale));
|
||||
}
|
||||
}
|
51
templates/index.html
Normal file
51
templates/index.html
Normal file
@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Power BI Measure Call Graph</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- D3.js for visualization -->
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Power BI Measure Call Graph</h1>
|
||||
</header>
|
||||
|
||||
<div class="connection-panel">
|
||||
<h2>Connect to Analysis Services</h2>
|
||||
<div class="form-group">
|
||||
<label for="server">Server:</label>
|
||||
<input type="text" id="server" placeholder="localhost">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="database">Database:</label>
|
||||
<input type="text" id="database" placeholder="AdventureWorks">
|
||||
</div>
|
||||
<button id="connect-btn">Connect</button>
|
||||
<div id="connection-status"></div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-container">
|
||||
<div class="controls">
|
||||
<button id="reset-btn">Reset View</button>
|
||||
<div class="search-box">
|
||||
<input type="text" id="search-input" placeholder="Search measures...">
|
||||
</div>
|
||||
</div>
|
||||
<div id="graph-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="measure-details">
|
||||
<h2>Measure Details</h2>
|
||||
<div id="measure-name"></div>
|
||||
<pre id="measure-expression"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/graph.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user