Showing preview only (209K chars total). Download the full file or copy to clipboard to get everything.
Repository: NovatecConsulting/novatec-service-dependency-graph-panel
Branch: master
Commit: bf97e1d64386
Files: 74
Total size: 190.4 KB
Directory structure:
gitextract_lgiult8p/
├── .circleci/
│ └── config.yml
├── .codeclimate.yml
├── .config/
│ ├── .eslintrc
│ ├── .prettierrc.js
│ ├── Dockerfile
│ ├── README.md
│ ├── jest/
│ │ ├── mocks/
│ │ │ └── react-inlinesvg.tsx
│ │ └── utils.js
│ ├── jest-setup.js
│ ├── jest.config.js
│ ├── tsconfig.json
│ ├── types/
│ │ └── custom.d.ts
│ └── webpack/
│ ├── constants.ts
│ ├── utils.ts
│ └── webpack.config.ts
├── .editorconfig
├── .eslintrc
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── feature_request.md
│ │ └── question.md
│ └── workflows/
│ └── build.yml
├── .gitignore
├── .nvmrc
├── .prettierrc.js
├── .release-it.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── appveyor.yml
├── docker-compose.yaml
├── docs/
│ └── README.md
├── jest-setup.js
├── jest.config.js
├── package.json
├── provisioning/
│ ├── dashboards/
│ │ └── dashboards.yml
│ └── home/
│ └── home.json
├── scripts/
│ └── create-signed-plugin.sh
├── src/
│ ├── assets/
│ │ └── icons/
│ │ └── icon_index.json
│ ├── css/
│ │ └── novatec-service-dependency-graph-panel.css
│ ├── dummy_data_frame.ts
│ ├── migration/
│ │ └── PanelMigration.tsx
│ ├── module.test.ts
│ ├── module.ts
│ ├── options/
│ │ ├── DefaultSettings.tsx
│ │ ├── TypeAheadTextfield/
│ │ │ ├── TypeaheadTextfield.css
│ │ │ └── TypeaheadTextfield.tsx
│ │ ├── dummyDataSwitch/
│ │ │ └── DummyDataSwitch.tsx
│ │ ├── iconMapping/
│ │ │ ├── IconMapping.css
│ │ │ └── IconMapping.tsx
│ │ └── options.tsx
│ ├── panel/
│ │ ├── PanelController.tsx
│ │ ├── asset_utils.tsx
│ │ ├── canvas/
│ │ │ ├── collision_detector.ts
│ │ │ ├── graph_canvas.ts
│ │ │ └── particle_engine.ts
│ │ ├── layout_options.ts
│ │ ├── serviceDependencyGraph/
│ │ │ ├── ServiceDependencyGraph.css
│ │ │ └── ServiceDependencyGraph.tsx
│ │ └── statistics/
│ │ ├── IncomingStatistics.tsx
│ │ ├── NodeStatistics.tsx
│ │ ├── SortableTable.tsx
│ │ ├── Statistics.css
│ │ ├── Statistics.tsx
│ │ └── utils/
│ │ └── Utils.ts
│ ├── plugin.json
│ ├── processing/
│ │ ├── graph_generator.ts
│ │ ├── node_substitutor.ts
│ │ ├── node_tree.ts
│ │ ├── pre_processor.ts
│ │ └── utils/
│ │ └── Utils.ts
│ ├── types.tsx
│ └── typings/
│ └── index.d.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .circleci/config.yml
================================================
version: 2.1
parameters:
ssh-fingerprint:
type: string
default: ${GITHUB_SSH_FINGERPRINT}
aliases:
# Workflow filters
- &filter-only-master
branches:
only: master
- &filter-only-release
branches:
only: /^v[1-9]*[0-9]+\.[1-9]*[0-9]+\.x$/
workflows:
plugin_workflow:
jobs:
- build
executors:
default_exec: # declares a reusable executor
docker:
- image: srclosson/grafana-plugin-ci-alpine:latest
e2e_exec:
docker:
- image: srclosson/grafana-plugin-ci-e2e:latest
jobs:
build:
executor: default_exec
steps:
- checkout
- restore_cache:
name: restore node_modules
keys:
- build-cache-{.Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
- run:
name: Install dependencies
command: |
mkdir ci
[ -f ~/project/node_modules/.bin/grafana-toolkit ] || yarn install --frozen-lockfile
- save_cache:
name: save node_modules
paths:
- ~/project/node_modules
key: build-cache-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
- run:
name: Build and test frontend
command: ./node_modules/.bin/grafana-toolkit plugin:ci-build
- run:
name: Move results to ci folder
command: ./node_modules/.bin/grafana-toolkit plugin:ci-build --finish
- run:
name: Package distribution
command: |
./node_modules/.bin/grafana-toolkit plugin:ci-package
- persist_to_workspace:
root: .
paths:
- ci/jobs/package
- ci/packages
- ci/dist
- ci/grafana-test-env
- store_artifacts:
path: ci
================================================
FILE: .codeclimate.yml
================================================
exclude_patterns:
- "dist"
- "**/node_modules/"
- "**/*.d.ts"
- "src/libs/"
- "src/images/"
- "src/img/"
- "src/screenshots/"
================================================
FILE: .config/.eslintrc
================================================
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-eslint-config
*/
{
"extends": ["@grafana/eslint-config"],
"root": true,
"rules": {
"react/prop-types": "off"
}
}
================================================
FILE: .config/.prettierrc.js
================================================
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in .config/README.md
*/
module.exports = {
"endOfLine": "auto",
"printWidth": 120,
"trailingComma": "es5",
"semi": true,
"jsxSingleQuote": false,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2
};
================================================
FILE: .config/Dockerfile
================================================
ARG grafana_version=latest
ARG grafana_image=grafana-enterprise
FROM grafana/${grafana_image}:${grafana_version}
# Make it as simple as possible to access the grafana instance for development purposes
# Do NOT enable these settings in a public facing / production grafana instance
ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin"
ENV GF_AUTH_ANONYMOUS_ENABLED "true"
ENV GF_AUTH_BASIC_ENABLED "false"
# Set development mode so plugins can be loaded without the need to sign
ENV GF_DEFAULT_APP_MODE "development"
# Inject livereload script into grafana index.html
USER root
RUN sed -i 's/<\/body><\/html>/<script src=\"http:\/\/localhost:35729\/livereload.js\"><\/script><\/body><\/html>/g' /usr/share/grafana/public/views/index.html
================================================
FILE: .config/README.md
================================================
# Default build configuration by Grafana
**This is an auto-generated directory and is not intended to be changed! ⚠️**
The `.config/` directory holds basic configuration for the different tools
that are used to develop, test and build the project. In order to make it updates easier we ask you to
not edit files in this folder to extend configuration.
## How to extend the basic configs?
Bear in mind that you are doing it at your own risk, and that extending any of the basic configuration can lead
to issues around working with the project.
### Extending the ESLint config
Edit the `.eslintrc` file in the project root in order to extend the ESLint configuration.
**Example:**
```json
{
"extends": "./.config/.eslintrc",
"rules": {
"react/prop-types": "off"
}
}
```
---
### Extending the Prettier config
Edit the `.prettierrc.js` file in the project root in order to extend the Prettier configuration.
**Example:**
```javascript
module.exports = {
// Prettier configuration provided by Grafana scaffolding
...require('./.config/.prettierrc.js'),
semi: false,
};
```
---
### Extending the Jest config
There are two configuration in the project root that belong to Jest: `jest-setup.js` and `jest.config.js`.
**`jest-setup.js`:** A file that is run before each test file in the suite is executed. We are using it to
set up the Jest DOM for the testing library and to apply some polyfills. ([link to Jest docs](https://jestjs.io/docs/configuration#setupfilesafterenv-array))
**`jest.config.js`:** The main Jest configuration file that extends the Grafana recommended setup. ([link to Jest docs](https://jestjs.io/docs/configuration))
#### ESM errors with Jest
A common issue found with the current jest config involves importing an npm package which only offers an ESM build. These packages cause jest to error with `SyntaxError: Cannot use import statement outside a module`. To work around this we provide a list of known packages to pass to the `[transformIgnorePatterns](https://jestjs.io/docs/configuration#transformignorepatterns-arraystring)` jest configuration property. If need be this can be extended in the following way:
```javascript
process.env.TZ = 'UTC';
const { grafanaESModules, nodeModulesToTransform } = require('./config/jest/utils');
module.exports = {
// Jest configuration provided by Grafana
...require('./.config/jest.config'),
// Inform jest to only transform specific node_module packages.
transformIgnorePatterns: [nodeModulesToTransform([...grafanaESModules, 'packageName'])],
};
```
---
### Extending the TypeScript config
Edit the `tsconfig.json` file in the project root in order to extend the TypeScript configuration.
**Example:**
```json
{
"extends": "./.config/tsconfig.json",
"compilerOptions": {
"preserveConstEnums": true
}
}
```
---
### Extending the Webpack config
Follow these steps to extend the basic Webpack configuration that lives under `.config/`:
#### 1. Create a new Webpack configuration file
Create a new config file that is going to extend the basic one provided by Grafana.
It can live in the project root, e.g. `webpack.config.ts`.
#### 2. Merge the basic config provided by Grafana and your custom setup
We are going to use [`webpack-merge`](https://github.com/survivejs/webpack-merge) for this.
```typescript
// webpack.config.ts
import type { Configuration } from 'webpack';
import { merge } from 'webpack-merge';
import grafanaConfig from './.config/webpack/webpack.config';
const config = async (env): Promise<Configuration> => {
const baseConfig = await grafanaConfig(env);
return merge(baseConfig, {
// Add custom config here...
output: {
asyncChunks: true,
},
});
};
export default config;
```
#### 3. Update the `package.json` to use the new Webpack config
We need to update the `scripts` in the `package.json` to use the extended Webpack configuration.
**Update for `build`:**
```diff
-"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",
+"build": "webpack -c ./webpack.config.ts --env production",
```
**Update for `dev`:**
```diff
-"dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development",
+"dev": "webpack -w -c ./webpack.config.ts --env development",
```
### Configure grafana image to use when running docker
By default `grafana-enterprise` will be used as the docker image for all docker related commands. If you want to override this behaviour simply alter the `docker-compose.yaml` by adding the following build arg `grafana_image`.
**Example:**
```yaml
version: '3.7'
services:
grafana:
container_name: 'myorg-basic-app'
build:
context: ./.config
args:
grafana_version: ${GRAFANA_VERSION:-9.1.2}
grafana_image: ${GRAFANA_IMAGE:-grafana}
```
In this example we are assigning the environment variable `GRAFANA_IMAGE` to the build arg `grafana_image` with a default value of `grafana`. This will give you the possibility to set the value while running the docker-compose commands which might be convinent in some scenarios.
---
================================================
FILE: .config/jest/mocks/react-inlinesvg.tsx
================================================
// Due to the grafana/ui Icon component making fetch requests to
// `/public/img/icon/<icon_name>.svg` we need to mock react-inlinesvg to prevent
// the failed fetch requests from displaying errors in console.
import React from 'react';
type Callback = (...args: any[]) => void;
export interface StorageItem {
content: string;
queue: Callback[];
status: string;
}
export const cacheStore: { [key: string]: StorageItem } = Object.create(null);
const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/;
const InlineSVG = ({ src }: { src: string }) => {
// testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`)
const testId = src.replace(SVG_FILE_NAME_REGEX, '$2');
return <svg xmlns="http://www.w3.org/2000/svg" data-testid={testId} viewBox="0 0 24 24" />;
};
export default InlineSVG;
================================================
FILE: .config/jest/utils.js
================================================
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in .config/README.md
*/
/*
* This utility function is useful in combination with jest `transformIgnorePatterns` config
* to transform specific packages (e.g.ES modules) in a projects node_modules folder.
*/
const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!.*(${moduleNames.join('|')})\/.*)`;
// Array of known nested grafana package dependencies that only bundle an ESM version
const grafanaESModules = [
'.pnpm', // Support using pnpm symlinked packages
'@grafana/schema',
'd3',
'd3-color',
'd3-force',
'd3-interpolate',
'd3-scale-chromatic',
'ol',
'react-colorful',
'rxjs',
'uuid',
];
module.exports = {
nodeModulesToTransform,
grafanaESModules,
};
================================================
FILE: .config/jest-setup.js
================================================
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-jest-config
*/
import '@testing-library/jest-dom';
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(global, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
HTMLCanvasElement.prototype.getContext = () => {};
================================================
FILE: .config/jest.config.js
================================================
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-jest-config
*/
const path = require('path');
const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils');
module.exports = {
moduleNameMapper: {
'\\.(css|scss|sass)$': 'identity-obj-proxy',
'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'),
},
modulePaths: ['<rootDir>/src'],
setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
testEnvironment: 'jest-environment-jsdom',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}',
],
transform: {
'^.+\\.(t|j)sx?$': [
'@swc/jest',
{
sourceMaps: 'inline',
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
decorators: false,
dynamicImport: true,
},
},
},
],
},
// Jest will throw `Cannot use import statement outside module` if it tries to load an
// ES module without it being transformed first. ./config/README.md#esm-errors-with-jest
transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)],
};
================================================
FILE: .config/tsconfig.json
================================================
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-typescript-config
*/
{
"compilerOptions": {
"alwaysStrict": true,
"declaration": false,
"rootDir": "../src",
"baseUrl": "../src",
"typeRoots": ["../node_modules/@types"],
"resolveJsonModule": true
},
"ts-node": {
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"esModuleInterop": true
},
"transpileOnly": true
},
"include": ["../src", "./types"],
"extends": "@grafana/tsconfig"
}
================================================
FILE: .config/types/custom.d.ts
================================================
// Image declarations
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.svg' {
const content: string;
export default content;
}
// Font declarations
declare module '*.woff';
declare module '*.woff2';
declare module '*.eot';
declare module '*.ttf';
declare module '*.otf';
================================================
FILE: .config/webpack/constants.ts
================================================
export const SOURCE_DIR = 'src';
export const DIST_DIR = 'dist';
================================================
FILE: .config/webpack/utils.ts
================================================
import fs from 'fs';
import process from 'process';
import os from 'os';
import path from 'path';
import util from 'util';
import { glob } from 'glob';
import { SOURCE_DIR } from './constants';
export function isWSL() {
if (process.platform !== 'linux') {
return false;
}
if (os.release().toLowerCase().includes('microsoft')) {
return true;
}
try {
return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
} catch {
return false;
}
}
export function getPackageJson() {
return require(path.resolve(process.cwd(), 'package.json'));
}
export function getPluginJson() {
return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`));
}
export function hasReadme() {
return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md'));
}
// Support bundling nested plugins by finding all plugin.json files in src directory
// then checking for a sibling module.[jt]sx? file.
export async function getEntries(): Promise<Record<string, string>> {
const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true });
const plugins = await Promise.all(pluginsJson.map((pluginJson) => {
const folder = path.dirname(pluginJson);
return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true });
})
);
return plugins.reduce((result, modules) => {
return modules.reduce((result, module) => {
const pluginPath = path.dirname(module);
const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, '');
const entryName = pluginName === '' ? 'module' : `${pluginName}/module`;
result[entryName] = module;
return result;
}, result);
}, {});
}
================================================
FILE: .config/webpack/webpack.config.ts
================================================
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-webpack-config
*/
import CopyWebpackPlugin from 'copy-webpack-plugin';
import ESLintPlugin from 'eslint-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import LiveReloadPlugin from 'webpack-livereload-plugin';
import path from 'path';
import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin';
import { Configuration } from 'webpack';
import { getPackageJson, getPluginJson, hasReadme, getEntries, isWSL } from './utils';
import { SOURCE_DIR, DIST_DIR } from './constants';
const pluginJson = getPluginJson();
const config = async (env): Promise<Configuration> => {
const baseConfig: Configuration = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
},
context: path.join(process.cwd(), SOURCE_DIR),
devtool: env.production ? 'source-map' : 'eval-source-map',
entry: await getEntries(),
externals: [
'lodash',
'jquery',
'moment',
'slate',
'emotion',
'@emotion/react',
'@emotion/css',
'prismjs',
'slate-plain-serializer',
'@grafana/slate-react',
'react',
'react-dom',
'react-redux',
'redux',
'rxjs',
'react-router',
'react-router-dom',
'd3',
'angular',
'@grafana/ui',
'@grafana/runtime',
'@grafana/data',
// Mark legacy SDK imports as external if their name starts with the "grafana/" prefix
({ request }, callback) => {
const prefix = 'grafana/';
const hasPrefix = (request) => request.indexOf(prefix) === 0;
const stripPrefix = (request) => request.substr(prefix.length);
if (hasPrefix(request)) {
return callback(undefined, stripPrefix(request));
}
callback();
},
],
mode: env.production ? 'production' : 'development',
module: {
rules: [
{
exclude: /(node_modules)/,
test: /\.[tj]sx?$/,
use: {
loader: 'swc-loader',
options: {
jsc: {
baseUrl: './src',
target: 'es2015',
loose: false,
parser: {
syntax: 'typescript',
tsx: true,
decorators: false,
dynamicImport: true,
},
},
},
},
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"]
},
{
test: /\.s[ac]ss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.(png|jpe?g|gif|svg)$/,
type: 'asset/resource',
generator: {
// Keep publicPath relative for host.com/grafana/ deployments
publicPath: `public/plugins/${pluginJson.id}/img/`,
outputPath: 'img/',
filename: Boolean(env.production) ? '[hash][ext]' : '[name][ext]',
},
},
{
test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/,
type: 'asset/resource',
generator: {
// Keep publicPath relative for host.com/grafana/ deployments
publicPath: `public/plugins/${pluginJson.id}/fonts/`,
outputPath: 'fonts/',
filename: Boolean(env.production) ? '[hash][ext]' : '[name][ext]',
},
},
],
},
output: {
clean: {
keep: new RegExp(`(.*?_(amd64|arm(64)?)(.exe)?|go_plugin_build_manifest)`),
},
filename: '[name].js',
library: {
type: 'amd',
},
path: path.resolve(process.cwd(), DIST_DIR),
publicPath: '/',
},
plugins: [
new CopyWebpackPlugin({
patterns: [
// If src/README.md exists use it; otherwise the root README
// To `compiler.options.output`
{ from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true },
{ from: 'plugin.json', to: '.' },
{ from: '../LICENSE', to: '.' },
{ from: '../CHANGELOG.md', to: '.', force: true },
{ from: '**/*.json', to: '.' }, // TODO<Add an error for checking the basic structure of the repo>
{ from: '**/*.svg', to: '.', noErrorOnMissing: true }, // Optional
{ from: '**/*.png', to: '.', noErrorOnMissing: true }, // Optional
{ from: '**/*.html', to: '.', noErrorOnMissing: true }, // Optional
{ from: 'img/**/*', to: '.', noErrorOnMissing: true }, // Optional
{ from: 'libs/**/*', to: '.', noErrorOnMissing: true }, // Optional
{ from: 'static/**/*', to: '.', noErrorOnMissing: true }, // Optional
],
}),
// Replace certain template-variables in the README and plugin.json
new ReplaceInFileWebpackPlugin([
{
dir: DIST_DIR,
files: ['plugin.json', 'README.md'],
rules: [
{
search: /\%VERSION\%/g,
replace: getPackageJson().version,
},
{
search: /\%TODAY\%/g,
replace: new Date().toISOString().substring(0, 10),
},
{
search: /\%PLUGIN_ID\%/g,
replace: pluginJson.id,
},
],
},
]),
new ForkTsCheckerWebpackPlugin({
async: Boolean(env.development),
issue: {
include: [{ file: '**/*.{ts,tsx}' }],
},
typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') },
}),
new ESLintPlugin({
extensions: ['.ts', '.tsx'],
lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files
}),
...(env.development ? [new LiveReloadPlugin()] : []),
],
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
// handle resolving "rootDir" paths
modules: [path.resolve(process.cwd(), 'src'), 'node_modules'],
unsafeCache: true,
},
}
if(isWSL()) {
baseConfig.watchOptions = {
poll: 3000,
ignored: /node_modules/,
}}
return baseConfig;
};
export default config;
================================================
FILE: .editorconfig
================================================
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 120
[*.{js,ts,tsx,scss}]
quote_type = single
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .eslintrc
================================================
{
"extends": "./.config/.eslintrc"
}
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: "[BUG] - *ENTER TITLE*"
labels: bug
assignees: ''
---
Please provide as much and as detailed information as possible as it will make it easier for us to reproduce and fix the bug!
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
A clear and concise description of how to reproduce the bug.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Your Setup:**
- OS Grafana is running on:
- OS & Browser from which Grafana is accessed:
- Plugin-Version:
- Grafana-Version:
- Datasource & Version:
- Other:
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature] - *ENTER TITLE*"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/ISSUE_TEMPLATE/question.md
================================================
---
name: Question
about: Ask a question about the project.
title: "[Question] - *ENTER TITLE*"
labels: question
assignees: ''
---
**Write your question here**
**Screenshots**
**Where and how could we improve the readme?**
================================================
FILE: .github/workflows/build.yml
================================================
name: Build plugin
on:
push:
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:20.19
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Build plugin
run: |
yarn install && yarn build
================================================
FILE: .gitignore
================================================
node_modules
npm-debug.log
*.log
.vscode
dist
coverage
================================================
FILE: .nvmrc
================================================
16
================================================
FILE: .prettierrc.js
================================================
module.exports = {
// Prettier configuration provided by Grafana scaffolding
...require("./.config/.prettierrc.js")
};
================================================
FILE: .release-it.json
================================================
{
"github": {
"release": false
},
"npm": {
"publish": false
},
"hooks": {
"after:bump":
"rm -r dist; yarn build; npx @grafana/toolkit plugin:sign; cp -r dist novatec-sdg-panel; mkdir -p releases; zip -r releases/novatec-sdg-panel.zip novatec-sdg-panel; rm -r novatec-sdg-panel"
}
}
================================================
FILE: CHANGELOG.md
================================================
# Change Log
All notable changes to this project will be documented in this file.
## v4.2.0
Update dependencies
## v4.1.2
Add layering mechanism
Add collision between edge-labels
Update dependencies
## v4.0.2
Bug fix in icon path
## v4.0.1
SumTimings now working as expected
Response Time Health now displayed in node statistics
External Icons now use the correct path
## v4.0.0
Ported project to react.
aggregationType is not needed as template variable anymore.
Unit type of data now can be chosen.
Tables of Incoming/Outgoing Statistics are now sortable.
Settings needed for the dummy data to be displayed is now filled in automatically when dummy data is activated.
Service Icons can now be customized for both Internal and External Services.
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing
## Releasing
To create a new release of the plugin, follow these steps.
You may also read the [official documentation](https://grafana.com/developers/plugin-tools/publish-a-plugin/publish-a-plugin).
### 1. Push a new tag
Push the current state of the plugin to the remote GitHub repository.
`git tag <version>`
`git tag push origin -tag <version>`
### 2. Create a release
Create a new release from the pushed tag manually in GitHub.
Add appropriate release notes.
### 3. Add the signed plugin to the release
To sign a plugin, you will need a **plugin signing token**,
which has been created in our Grafana Cloud account.
Set the environment variable `GRAFANA_ACCESS_POLICY_TOKEN` via
`export GRAFANA_ACCESS_POLICY_TOKEN=<token>`
or in Windows
`set GRAFANA_ACCESS_POLICY_TOKEN=<token>`
Then run the release script to create a signed plugin:
`./scripts/create-signed-plugin.sh`
Add the created zip file in the `/release` directory to the GitHub release.
### 4. Submit the plugin in Grafana
If you want to publish the release in the Grafana marketplace, you will have to submit the
release in our Grafana Cloud account.
You will need to provide:
- URL of the plugin (link to the zip file of the release)
- SHA1 of the plugin (SHA1 hash of the zip file)
- Source code URL (URL of the repository for the release tag)
- Test description (use the `docker-compose.yml` to run the plugin with dummy data)
================================================
FILE: LICENSE
================================================
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
Copyright 2025 Novatec Consulting GmbH
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.
================================================
FILE: README.md
================================================
## Novatec Service Dependency Graph Panel

[](LICENSE)

**Version 4.2.0 is only compatible with Grafana from version 10.4.0!**
**Version 4.0.0 is only compatible with Grafana from version 7.1.0!**
The Service Dependency Graph Panel by [Novatec](https://www.novatec-gmbh.de/en/) provides you with many features such as monitoring
your latencies, errors and requests of your desired service. This interactive panel for [Grafana](https://grafana.com/) will help you
visualize the processes of your application much better.
### Updating the Service Dependency Graph Panel
The file structure for the icon mapping has changed for version 4.0.0. **Icons are now located in the path 'plugins/novatec-sdg-panel/assets/icons/'.** This also applies to custom icons!
___
## Configuration of the Data Source
### Using Static Dummy Data
If you want to get a first impression of this panel without having your own data source yet, the panels provides you some dummy data to play around with.
The dummy data is basically a snapshot of multiple query results in the table format. You'll find its source [here](https://github.com/NovatecConsulting/novatec-service-dependency-graph-panel/blob/master/src/dummy_data_frame.ts), in the panel's GitHub repository.
Depending on the query result, the data provides the following tags:
* **service**: The service (application) the data is related to.
* **namespace**: The namespace of a service. Every literal divided by "." corresponds to one level of a namespace. For instance **demo.infrastructure**.
* **protocol**: The communication type (e.g. HTTP, JMS, ...).
* **origin_service**: In case of an incoming communication, this is the origin service.
* **target_service**: In case of an outgoing communication, this is the target service.
* **origin_external**: The origin of an incoming communication, which cannot be correlated to a known service (e.g. HTTP request of a third party application).
* **target_external**: The target of an outgoing communication, which cannot be correlated to a known service (e.g. third party HTTP endpoint).
Depending on the query result, the data provides the following fields:
* **in_timesum**: The total sum of all incoming request response times. (Prometheus style)
* **in_count**: The total amount of incoming requests.
* **error_in**: The amount of incoming requests which produced an error.
* **out_timesum**: The total sum of all outgoing request response times. (Prometheus style)
* **out_count**: The total amount of outgoing requests.
* **error_out**: The amount of outgoing requests which produced an error.
* **threshold**: The critical threshold in milliseconds for the response times of incoming requests.
In order to use this data you simply have to activate the Dummy Data Switch you can find in the General Settings. All necessary options will be applied.
After activating the Dummy Data your Data Mapping should look like this:
| key | value |
| --- | --- |
| Response Time | in_timesum |
| Request Rate | in_count |
| Error Rate | error_in |
| Response Time (Outgoing) | out_timesum |
| Request Rate (Outgoing) | out_count |
| Error Rate (Outgoing) | error_out |
| Response Time Baseline (Upper) | threshold |
_Note that you may have to refresh the dashboard or reload the page in order for it to work._
##### Live example dummy data
Downloading and launching the [inspectIT Ocelot demo #1](https://inspectit.github.io/inspectit-ocelot/docs/getting-started/docker-examples) will provide you with live dummy data rather than static one.
Just open the docker images' Grafana and choose the dashboard `Service Graph` to see the fully functional Service Dependency Graph.
___
### Use your own Data Source
If you now want to use your own data source you have make sure, that the data received is in the `TABLE` format and is structured as follows:
* The table requires at least one column which specifies the connection's source or target. The settings `Source Component Column` and `Target Component Column` need to be set to the exact namings of the respective fields.
* The data can contain multiple value columns. These columns have to be mapped on specific attributes using the panel's `Data Mappings` options.
**Example**: Assuming the data table contains a column named `req_rate` which values represents a request rate for the related connection in the current time window. In order to correctly visualize these values as a request rate, the `Request Rate Column` option has to be set to `req_rate` - the column's name.
#### Examples
##### Example 1
If the previously described requirements are respected, a minimal table can be as follows:
| app | target_app | req_rate |
| --- | --- | --- |
| service a | service b | 50 |
| service a | service c | 75 |
| service c | service d | 25 |
Assuming the panel's settings are specified as seen in the screenshot, the panel will visualize the data as following:

> Note: It is important to know that connections can only be generated if at least one request-rate column (incoming or outgoing) is defined.
##### Example 2
In this example, we extend the data table of example 1 by another column, representing the total sum of all request response times of a specific connection (e.g. sum of all HTTP request response times).
| app | target_app | req_rate | resp_time |
| --- | --- | --- | --- |
| service a | service b | 50 | 4000 |
| service a | service c | 75 | 13650 |
| service c | service d | 25 | 750 |
Now, the panel's `Data Mappings` option `Response Time Column` is set to `resp_time`. This specifies that the value in the `resp_time` column should be handled as the response time for a connection. By default, the values in this column will be handled as a sum of all response times - kind of a Prometheus style metric. This behavior can be changed by using the `Handle Timings as Sums` option. This table will result in the following visualization.

___
## Service Icons
The service dependency graph plugin allows you to display your own symbols in the drawn nodes.
For this purpose the option 'Service Icon Mapping' can be used.
Here you can specify an assignment of icons to certain name patterns.
All nodes that match the specified pattern (regular expression) will get the icon.

##### Example
A sample assignment is included by default: `Pattern: java // Icon: java`.
This means that all nodes which have `java` in their name get the `java` icon.
#### Custom Service Icons
You can add custom icons, by putting them into the plugin's `/assets/icons/` directory.
The file type **has to be `PNG`** and the icon itself and **has to be square**.
In order to be able to use the icon, its name (without its ending) has to be put into the array contained in the `icon_index.json` file located in the `/assets/icons/` directory.
##### Example
If the `icon_index.json` has the following content:
```
["java", "star_trek"]
```
it is assumed that the files `java.png` and `star_trek.png` is existing in the `/assets/icons/` directory.
___
### Tracing Drilldown
The service dependency graph plugin allows you to specify a backend URL for each drawn node.
For this purpose the option 'Tracing Drilldown' can be used.
Here you can specify a backend URL. An open and closed curly bracket `{}` is the placeholder for the selected node.
Each node will get an arrow icon in the details view. This icon is a link to your backend, specified in the options.
The curly brackets `{}` will be replaced with the selected node.
#### Example
`http://{}/my/awesome/path` will end up to `http://customers-service/my/awesome/path` when you select the `customers-service`.
___
### Layering
From version 4.1.0, the Service Dependency Graph Panel supports layering service nodes by their respective namespace.
#### Setup
To use this feature, add a tag containing the namespace of your service to your data. Then set the corresponding option `Namespace Column` in the panel's options to the name of this tag. If you have more than one namespace layer you want to be represented by the panel, you can separate multiple namespaces within your namespace tag by a certain character. This character must be set as the `Namespace Delimiter` in the panel's options. The default delimiter is `.`. Hence, if the content of a namespace column would be `my.awesome.namespace`, the graph would be built with `my` as layer 0, `awesome` as layer 1, and `namespace` as layer 2. Your respective service would then be on layer 3.
#### Usage
You can control the layer of your panel by using the (+) and (-) buttons on the panel's top-right. (+) increases the layer currently displayed, (-) decreases the layer.
___
## Create a Release
To create a release bundle, ensure `release-it` is installed:
```
npm install --global release-it
```
To build a release bundle:
```
release-it [--no-git.requireCleanWorkingDir]
```
### Found a bug? Have a question? Wanting to contribute?
Feel free to open up an issue. We will take care of you and provide as much help as needed. Any suggestions/contributions are being very much appreciated.
================================================
FILE: appveyor.yml
================================================
# Test against the latest version of this Node.js version
environment:
nodejs_version: "12"
# Local NPM Modules
cache:
- node_modules
# Install scripts. (runs after repo cloning)
install:
# Get the latest stable version of Node.js or io.js
- ps: Install-Product node $env:nodejs_version
# install modules
- npm install -g yarn --quiet
- yarn install --pure-lockfile
# Post-install test scripts.
test_script:
# Output useful info for debugging.
- node --version
- npm --version
# Run the build
build_script:
- yarn dev # This will also run prettier!
- yarn build # make sure both scripts work
================================================
FILE: docker-compose.yaml
================================================
services:
grafana:
container_name: 'novatec-sdg-panel'
platform: "linux/amd64"
build:
context: ./.config
args:
grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise}
grafana_version: ${GRAFANA_VERSION:-11.6.0}
environment:
- GF_PATHS_PROVISIONING=/usr/share/grafana/custom/
ports:
- 3000:3000/tcp
volumes:
- ./dist:/var/lib/grafana/plugins/novatec-sdg-panel
- ./provisioning:/usr/share/grafana/custom/
- ./provisioning/home/home.json:/usr/share/grafana/public/dashboards/home.json
================================================
FILE: docs/README.md
================================================
TODO: add example docs structure
================================================
FILE: jest-setup.js
================================================
// Jest setup provided by Grafana scaffolding
import './.config/jest-setup';
================================================
FILE: jest.config.js
================================================
// force timezone to UTC to allow tests to work regardless of local timezone
// generally used by snapshots, but can affect specific tests
process.env.TZ = 'UTC';
module.exports = {
// Jest configuration provided by Grafana scaffolding
...require('./.config/jest.config'),
};
================================================
FILE: package.json
================================================
{
"name": "novatec-service-dependency-graph-panel",
"version": "4.2.0",
"description": "Service Dependency Graph panel for Grafana",
"main": "src/module.js",
"scripts": {
"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",
"dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development",
"e2e": "yarn exec cypress install && yarn exec grafana-e2e run",
"e2e:update": "yarn exec cypress install && yarn exec grafana-e2e run --update-screenshots",
"lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .",
"lint:fix": "yarn run lint --fix",
"server": "docker-compose up --build",
"sign": "npx --yes @grafana/sign-plugin@latest",
"test": "jest --watch --onlyChanged",
"test:ci": "jest --passWithNoTests --maxWorkers 4",
"typecheck": "tsc --noEmit"
},
"keywords": [
"grafana",
"plugin",
"service-dependency-graph",
"topology"
],
"repository": "github:grafana/NovatecConsulting/novatec-service-dependency-graph-panel",
"author": "Novatec",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/NovatecConsulting/novatec-service-dependency-graph-panel/issues",
"email": "plugins@grafana.com"
},
"devDependencies": {
"@babel/core": "^7.26.10",
"@babel/preset-env": "^7.23.9",
"@grafana/e2e": "^10.1.8",
"@grafana/e2e-selectors": "^10.4.0",
"@grafana/eslint-config": "^6.0.0",
"@grafana/tsconfig": "^1.2.0-rc1",
"@swc/core": "1.3.75",
"@swc/helpers": "^0.5.15",
"@swc/jest": "^0.2.37",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.16",
"@types/node": "^20.11.17",
"@types/react-cytoscapejs": "^1.2.5",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"emotion": "10.0.27",
"eslint-webpack-plugin": "^4.2.0",
"fork-ts-checker-webpack-plugin": "^8.0.0",
"glob": "^10.4.5",
"identity-obj-proxy": "3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.5.3",
"replace-in-file-webpack-plugin": "^1.0.6",
"sass": "1.63.2",
"sass-loader": "13.3.1",
"style-loader": "3.3.3",
"swc-loader": "^0.2.6",
"ts-jest": "^28.0.4",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3",
"webpack": "^5.94.0",
"webpack-cli": "^6.0.1",
"webpack-livereload-plugin": "^3.0.2"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@emotion/css": "^11.13.5",
"@grafana/data": "^10.4.0",
"@grafana/runtime": "^10.4.0",
"@grafana/ui": "^10.4.0",
"@types/react-autosuggest": "^10.1.11",
"@types/react-bootstrap-table-next": "^4.0.26",
"babel-preset-react": "^6.24.1",
"cytoscape": "^3.31.1",
"cytoscape-canvas": "^3.0.1",
"cytoscape-cola": "^2.5.1",
"human-format": "^0.11.0",
"react": "^18.3.1",
"react-autosuggest": "^10.1.0",
"react-bootstrap-table-next": "^4.0.3",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^18.3.1",
"react-native": "^0.78.1"
},
"packageManager": "yarn@1.22.22",
"resolutions": {
"underscore": "^1.12.1",
"semver": "^6.3.1"
}
}
================================================
FILE: provisioning/dashboards/dashboards.yml
================================================
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: ''
type: file
disableDeletion: true
allowUiUpdates: true
updateIntervalSeconds: 10 #how often Grafana will scan for changed dashboards
options:
path: /usr/share/grafana/custom/dashboards
================================================
FILE: provisioning/home/home.json
================================================
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 1,
"links": [],
"panels": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"gridPos": {
"h": 17,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"dataMapping": {
"aggregationType": "service",
"baselineRtUpper": "threshold",
"errorRateColumn": "error_in",
"errorRateOutgoingColumn": "error_out",
"extOrigin": "origin_external",
"extTarget": "target_external",
"requestRateColumn": "in_count",
"requestRateOutgoingColumn": "out_count",
"responseTimeColumn": "in_timesum",
"responseTimeOutgoingColumn": "out_timesum",
"showDummyData": true,
"sourceColumn": "origin_service",
"targetColumn": "target_service",
"type": "protocol"
},
"drillDownLink": "",
"externalIcons": [
{
"filename": "web",
"pattern": "web"
},
{
"filename": "message",
"pattern": "jms"
},
{
"filename": "database",
"pattern": "jdbc"
},
{
"filename": "http",
"pattern": "http"
}
],
"filterEmptyConnections": true,
"icons": [
{
"filename": "java",
"pattern": "java"
},
{
"filename": "star_trek",
"pattern": "spok|star trek"
}
],
"showBaselines": false,
"showConnectionStats": true,
"showDebugInformation": false,
"style": {
"dangerColor": "rgb(196, 22, 42)",
"healthyColor": "rgb(87, 148, 242)",
"noDataColor": "rgb(123, 123, 138)"
},
"sumTimings": true,
"timeFormat": "m"
},
"pluginVersion": "4.2.0",
"title": "SDG",
"type": "novatec-sdg-panel"
},
{
"gridPos": {
"h": 9,
"w": 24,
"x": 0,
"y": 17
},
"id": 123125,
"type": "gettingstarted"
},
{
"gridPos": {
"h": 9,
"w": 24,
"x": 0,
"y": 26
},
"id": 123124,
"type": "gettingstarted"
},
{
"gridPos": {
"h": 9,
"w": 24,
"x": 0,
"y": 35
},
"id": 123123,
"type": "gettingstarted"
}
],
"schemaVersion": 39,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Dummy Dashboard",
"version": 1,
"weekStart": ""
}
================================================
FILE: scripts/create-signed-plugin.sh
================================================
#!/bin/bash
echo Building plugin...
yarn install && yarn build
echo Signing plugin...
yarn sign
echo Creating zip file...
cp -r dist novatec-sdg-panel
mkdir -p release && zip -r release/novatec-sdg-panel.zip novatec-sdg-panel
rm -r novatec-sdg-panel
================================================
FILE: src/assets/icons/icon_index.json
================================================
["java", "star_trek", "balancer", "database", "default", "ftp", "http", "ldap", "mainframe", "message", "smtp", "web"]
================================================
FILE: src/css/novatec-service-dependency-graph-panel.css
================================================
.service-dependency-graph-panel {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.service-dependency-graph-panel .graph-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
}
.service-dependency-graph-panel .service-dependency-graph {
position: relative;
flex-grow: 1;
min-width: 0;
}
.service-dependency-graph-panel .canvas-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.service-dependency-graph-panel .zoom-button-container {
position: absolute;
top: 0;
right: 1rem;
z-index: 99;
width: 35px;
}
.service-dependency-graph-panel .statistics {
flex-basis: 0;
transition: flex-basis 250ms ease-in-out;
overflow-y: scroll;
}
.service-dependency-graph-panel .statistics.show {
flex-basis: 30rem;
padding-left: 0.5%;
}
.service-dependency-graph-panel .header--selection {
font-size: 1.25em;
text-align: center;
border-bottom: 2px solid #161719;
font-weight: 500;
color: rgb(216, 217, 218);
}
.service-dependency-graph-panel .secondHeader--selection {
font-size: 1.2em;
text-align: center;
padding-top: 1.5rem;
padding-bottom: 0.5rem;
}
.service-dependency-graph-panel .no-data--selection{
color: #888888;
text-align: center;
}
.service-dependency-graph-panel .table--selection {
width: 99%;
table-layout: fixed;
}
.service-dependency-graph-panel .table--selection th, .table--selection td {
padding: 3px 5px;
}
.service-dependency-graph-panel .table--selection tr {
border-bottom: 2px solid #161719;
}
.service-dependency-graph-panel .table--td--selection {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-dependency-graph-panel .threshold--bad {
color:#f2495c;
}
.service-dependency-graph-panel .threshold--good {
color: #73bf69;
}
.service-dependency-graph-panel .table--th--selectionSmall {
width: 5.5rem;
}
.service-dependency-graph-panel .table--th--selectionMedium {
width: 8rem;
}
.service-dependency-graph-panel .table--selection--head {
background-color: #28282a;
border-top: 2px solid #161719;
color: #33b5e5;
}
.service-dependency-graph-panel .width-100 {
width: 100%;
}
================================================
FILE: src/dummy_data_frame.ts
================================================
import { ArrayVector, DataFrame, FieldType } from '@grafana/data';
const data: DataFrame[] = [
{
refId: 'A',
name: undefined,
meta: undefined,
fields: [
{
name: 'time',
type: FieldType.time,
config: {},
values: new ArrayVector([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
},
{
name: 'origin_external',
type: FieldType.string,
config: {},
values: new ArrayVector([
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'tcp://localhost:61616',
'tcp://10.10.10.10:61616',
]),
},
{
name: 'origin_service',
type: FieldType.string,
config: {},
values: new ArrayVector([
'',
'',
'',
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'customers-service',
'vets-service',
'visits-service',
'vets-service',
'',
]),
},
{
name: 'protocol',
type: FieldType.string,
config: {},
values: new ArrayVector([
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'jms',
'jms',
]),
},
{
name: 'service',
type: FieldType.string,
config: {},
values: new ArrayVector([
'api-gateway',
'config-server',
'discovery-server',
'api-gateway',
'customers-service',
'discovery-server',
'vets-service',
'visits-service',
'discovery-server',
'discovery-server',
'discovery-server',
'visits-service',
'api-gateway',
]),
},
{
name: 'namespace',
type: FieldType.string,
config: {},
values: new ArrayVector([
'demo.infrastructure',
'demo.infrastructure',
'demo.infrastructure',
'demo.infrastructure',
'demo.domain-logic',
'demo.infrastructure',
'demo.domain-logic',
'demo.domain-logic',
'demo.infrastructure',
'demo.infrastructure',
'demo.infrastructure',
'demo.domain-logic',
'demo.infrastructure',
]),
},
{
name: 'in_count',
type: FieldType.number,
config: {},
values: new ArrayVector([508, 0, 0, 100, 347, 20, 63, 100, 20, 20, 20, 300, 300]),
},
],
length: 13,
},
{
refId: 'B',
name: undefined,
meta: undefined,
fields: [
{
name: 'time',
type: FieldType.time,
config: {},
values: new ArrayVector([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
},
{
name: 'protocol',
type: FieldType.string,
config: {},
values: new ArrayVector([
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'jms',
'http',
'jdbc',
'jdbc',
'jdbc',
]),
},
{
name: 'service',
type: FieldType.string,
config: {},
values: new ArrayVector([
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'config-server',
'customers-service',
'vets-service',
'vets-service',
'visits-service',
'customers-service',
'vets-service',
'visits-service',
]),
},
{
name: 'namespace',
type: FieldType.string,
config: {},
values: new ArrayVector([
'',
'',
'',
'',
'',
'web',
'web',
'',
'',
'',
'',
'demo.database',
'demo.database',
'demo.database',
]),
},
{
name: 'target_external',
type: FieldType.string,
config: {},
values: new ArrayVector([
'',
'',
'',
'',
'',
'7a8dce897616:8080',
'github.com',
'',
'',
'tcp://localhost:61616',
'',
'jdbc:hsqldb:mem:testdb',
'jdbc:hsqldb:mem:testdb',
'jdbc:hsqldb:mem:testdb',
]),
},
{
name: 'target_service',
type: FieldType.string,
config: {},
values: new ArrayVector([
'api-gateway',
'customers-service',
'discovery-server',
'vets-service',
'visits-service',
'',
'',
'discovery-server',
'discovery-server',
'visits-service',
'discovery-server',
'',
'',
'',
]),
},
{
name: 'out_count',
type: FieldType.number,
config: {},
values: new ArrayVector([100, 347, 20, 62, 100, 0, 0, 20, 20, 300, 20, 1847, 441, 100]),
},
],
length: 14,
},
{
refId: 'C',
name: undefined,
meta: undefined,
fields: [
{ name: 'time', type: FieldType.time, config: {}, values: new ArrayVector([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) },
{
name: 'origin_service',
type: FieldType.string,
config: {},
values: new ArrayVector([
'',
'',
'',
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'customers-service',
'vets-service',
'visits-service',
]),
},
{
name: 'protocol',
type: FieldType.string,
config: {},
values: new ArrayVector([
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
]),
},
{
name: 'service',
type: FieldType.string,
config: {},
values: new ArrayVector([
'api-gateway',
'config-server',
'discovery-server',
'api-gateway',
'customers-service',
'discovery-server',
'vets-service',
'visits-service',
'discovery-server',
'discovery-server',
'discovery-server',
]),
},
{
name: 'namespace',
type: FieldType.string,
config: {},
values: new ArrayVector([
'demo.infrastructure',
'demo.infrastructure',
'demo.infrastructure',
'demo.infrastructure',
'demo.domain-logic',
'demo.infrastructure',
'demo.domain-logic',
'demo.domain-logic',
'demo.domain-logic',
'demo.domain-logic',
'demo.infrastructure',
'demo.infrastructure',
'demo.infrastructure',
]),
},
{
name: 'target_external',
type: FieldType.string,
config: {},
values: new ArrayVector(['', '', '', '', '', '', '', '', '', '', '']),
},
{
name: 'in_timesum',
type: FieldType.number,
config: {},
values: new ArrayVector([
45140.008427999986, 0, 0, 1511.9842349999872, 819.3634589999965, 21.881731999999943, 281.0465210000002,
325.85070300000007, 21.53124399999996, 21.40604300000001, 20.813048000000038,
]),
},
],
length: 11,
},
{
refId: 'D',
name: undefined,
meta: undefined,
fields: [
{
name: 'time',
type: FieldType.time,
config: {},
values: new ArrayVector([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
},
{
name: 'protocol',
type: FieldType.string,
config: {},
values: new ArrayVector([
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'http',
'jdbc',
'jdbc',
'jdbc',
]),
},
{
name: 'service',
type: FieldType.string,
config: {},
values: new ArrayVector([
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'api-gateway',
'config-server',
'customers-service',
'vets-service',
'visits-service',
'customers-service',
'vets-service',
'visits-service',
]),
},
{
name: 'namespace',
type: FieldType.string,
config: {},
values: new ArrayVector([
'',
'',
'',
'',
'',
'web',
'web',
'',
'',
'',
'demo.database',
'demo.database',
'demo.database',
]),
},
{
name: 'target_external',
type: FieldType.string,
config: {},
values: new ArrayVector([
'',
'',
'',
'',
'',
'7a8dce897616:8080',
'github.com',
'',
'',
'',
'jdbc:hsqldb:mem:testdb',
'jdbc:hsqldb:mem:testdb',
'jdbc:hsqldb:mem:testdb',
]),
},
{
name: 'target_service',
type: FieldType.string,
config: {},
values: new ArrayVector([
'api-gateway',
'customers-service',
'discovery-server',
'vets-service',
'visits-service',
'',
'',
'discovery-server',
'discovery-server',
'discovery-server',
'',
'',
'',
]),
},
{
name: 'out_timesum',
type: FieldType.number,
config: {},
values: new ArrayVector([
1700.468872999987, 1481.533606999972, 540.746261, 501.65547400000014, 394.81158100000175, 0, 0,
84.59527999999978, 381.87400800000023, 225.65933600000017, 35.9093940000007, 13.000189000000091,
12.258137999999946,
]),
},
],
length: 13,
},
{
refId: 'E',
name: undefined,
meta: undefined,
fields: [
{ name: 'time', type: FieldType.time, config: {}, values: new ArrayVector([0, 0, 0, 0]) },
{ name: 'origin_service', type: FieldType.string, config: {}, values: new ArrayVector(['', '', '', '']) },
{
name: 'protocol',
type: FieldType.string,
config: {},
values: new ArrayVector(['http', 'http', 'http', 'http']),
},
{
name: 'service',
type: FieldType.string,
config: {},
values: new ArrayVector(['api-gateway', 'discovery-server', 'customers-service', 'vets-service']),
},
{
name: 'namespace',
type: FieldType.string,
config: {},
values: new ArrayVector([
'demo.infrastructure',
'demo.infrastructure',
'demo.domain-logic',
'demo.domain-logic',
]),
},
{ name: 'target_external', type: FieldType.string, config: {}, values: new ArrayVector(['', '', '', '']) },
{ name: 'error_in', type: FieldType.number, config: {}, values: new ArrayVector([14, 20, 20, 0]) },
],
length: 4,
},
{
refId: 'F',
name: undefined,
meta: undefined,
fields: [
{ name: 'time', type: FieldType.time, config: {}, values: new ArrayVector([0, 0, 0, 0]) },
{
name: 'origin_service',
type: FieldType.string,
config: {},
values: new ArrayVector(['api-gateway', 'api-gateway', 'api-gateway', 'customers-service']),
},
{
name: 'namespace',
type: FieldType.string,
config: {},
values: new ArrayVector(['demo.domain-logic', 'demo.domain-logic', 'demo.domain-logic', 'demo.infrastructure']),
},
{
name: 'protocol',
type: FieldType.string,
config: {},
values: new ArrayVector(['http', 'http', 'http', 'http']),
},
{
name: 'service',
type: FieldType.string,
config: {},
values: new ArrayVector(['customers-service', 'vets-service', 'visits-service', 'discovery-server']),
},
{ name: 'target_external', type: FieldType.string, config: {}, values: new ArrayVector(['', '', '', '']) },
{ name: 'error_out', type: FieldType.number, config: {}, values: new ArrayVector([14, 0, 0, 20]) },
],
length: 4,
},
{
refId: 'G',
name: undefined,
meta: undefined,
fields: [
{ name: 'time', type: FieldType.time, config: {}, values: new ArrayVector([0, 0]) },
{
name: 'service',
type: FieldType.string,
config: {},
values: new ArrayVector(['api-gateway', 'customers-service']),
},
{
name: 'namespace',
type: FieldType.string,
config: {},
values: new ArrayVector(['demo.infrastructure', 'demo.domain-logic']),
},
{ name: 'threshold', type: FieldType.number, config: {}, values: new ArrayVector([40.40604300000001, 10]) },
],
length: 2,
},
];
export default data;
================================================
FILE: src/migration/PanelMigration.tsx
================================================
import { PanelModel } from '@grafana/data';
import { DefaultSettings } from 'options/DefaultSettings';
import { PanelSettings } from 'types';
/**
* Checks if the given options are in the format of version < 4.0.0.
* @param options The options object which should be checked.
*/
function isLegacyFormat(options: any) {
return options && !('showDummyData' in options['dataMapping']);
}
/**
* Migrates the legacy iconMapping format to the iconMapping format of version > 4.0.0.
* @param iconMappings The iconMappings object to be migrated.
*/
function migrateIconMapping(iconMappings: any) {
const migratedIconMapping = [];
for (const iconMapping of iconMappings) {
migratedIconMapping.push({
pattern: iconMapping.name,
filename: iconMapping.filename,
});
}
return migratedIconMapping;
}
/**
* Migrates the legacy panel settings from version < 4.0.0 to the new format introduced in version 4.0.0
* The newly introduced variable aggregationType will be set to $aggregationTyoe in order to ensure functionality with
* the legacy setup of the panel.
* All other newly added options will be set to their respective default values.
* @param panel The panel object which should be migrated.
*/
export const PanelMigrationHandler = (panel: PanelModel<Partial<PanelSettings>> | any) => {
const { settings } = panel;
if (isLegacyFormat(settings)) {
return {
animate: settings.animate,
sumTimings: settings.sumTimings,
filterEmptyConnections: settings.filterEmptyConnections,
style: {
healthyColor: settings.style.healthyColor,
dangerColor: settings.style.dangerColor,
noDataColor: settings.style.unknownColor,
},
showDebugInformation: settings.showDebugInformation,
showConnectionStats: settings.showConnectionStats,
externalIcons: migrateIconMapping(settings.externalIcons),
icons: settings.serviceIcons,
dataMapping: {
aggregationType: '$aggregationType',
sourceColumn: settings.dataMapping.sourceComponentPrefix + '$aggregationType',
targetColumn: settings.dataMapping.targetComponentPrefix + '$aggregationType',
responseTimeColumn: settings.dataMapping.responseTimeColumn,
requestRateColumn: settings.dataMapping.requestRateColumn,
errorRateColumn: settings.dataMapping.errorRateColumn,
responseTimeOutgoingColumn: settings.dataMapping.responseTimeOutgoingColumn,
requestRateOutgoingColumn: settings.dataMapping.requestRateOutgoingColumn,
errorRateOutgoingColumn: settings.dataMapping.errorRateOutgoingColumn,
extOrigin: settings.dataMapping.extOrigin,
extTarget: settings.dataMapping.extTarget,
type: settings.dataMapping.type,
showDummyData: settings.showDummyData,
baselineRtUpper: settings.dataMapping.baselineRtUpper,
},
drillDownLink: settings.drillDownLink,
showBaselines: settings.showBaselines,
timeFormat: DefaultSettings.timeFormat,
};
}
return settings;
};
================================================
FILE: src/module.test.ts
================================================
// Just a stub test
describe('placeholder test', () => {
it('should return true', () => {
expect(true).toBeTruthy();
});
});
================================================
FILE: src/module.ts
================================================
import { PanelPlugin } from '@grafana/data';
import { PanelSettings } from './types';
import { PanelController } from './panel/PanelController';
import { optionsBuilder } from './options/options';
import { PanelMigrationHandler } from './migration/PanelMigration';
export const plugin = new PanelPlugin<PanelSettings>(PanelController)
.setPanelOptions(optionsBuilder)
.setMigrationHandler(PanelMigrationHandler);
================================================
FILE: src/options/DefaultSettings.tsx
================================================
import { PanelSettings } from '../types';
export const DefaultSettings: PanelSettings = {
animate: true,
dataMapping: {
aggregationType: 'service',
sourceColumn: 'origin_service',
targetColumn: 'target_service',
namespaceColumn: 'namespace',
namespaceDelimiter: '.',
responseTimeColumn: 'response-time',
requestRateColumn: 'request-rate',
errorRateColumn: 'error-rate',
responseTimeOutgoingColumn: 'response-time-out',
requestRateOutgoingColumn: 'request-rate-out',
errorRateOutgoingColumn: 'error-rate-out',
extOrigin: 'external_origin',
extTarget: 'external_target',
type: 'type',
baselineRtUpper: 'threshold',
showDummyData: false,
},
sumTimings: true,
filterEmptyConnections: true,
showDebugInformation: false,
showConnectionStats: true,
showBaselines: false,
style: {
healthyColor: 'rgb(87, 148, 242)',
dangerColor: 'rgb(196, 22, 42)',
noDataColor: 'rgb(123, 123, 138)',
},
icons: [
{
pattern: 'java',
filename: 'java',
},
{
pattern: 'spok|star trek',
filename: 'star_trek',
},
],
externalIcons: [
{
pattern: 'web',
filename: 'web',
},
{
pattern: 'jms',
filename: 'message',
},
{
pattern: 'jdbc',
filename: 'database',
},
{
pattern: 'http',
filename: 'http',
},
],
drillDownLink: '',
timeFormat: 'm',
};
================================================
FILE: src/options/TypeAheadTextfield/TypeaheadTextfield.css
================================================
.service-dependency-graph-panel .suggestion {
width: 100%;
border-right: 1px solid #3865AB ;
border-left: 1px solid #3865AB ;
background-color: #0B0C0E;
padding-left: 10px;
}
.service-dependency-graph-panel ul {
list-style-type: none;
}
.service-dependency-graph-panel ul:last-child {
border-bottom: 1px solid #3865AB ;
}
================================================
FILE: src/options/TypeAheadTextfield/TypeaheadTextfield.tsx
================================================
import React from 'react';
import Autosuggest, { InputProps } from 'react-autosuggest';
import { StandardEditorContext, StandardEditorProps } from '@grafana/data';
import './TypeaheadTextfield.css';
import { PanelSettings } from '../../types';
interface Props extends StandardEditorProps<string, PanelSettings> {
item: any;
value: string;
onChange: (value?: string) => void;
context: StandardEditorContext<any>;
}
interface State {
item: any;
value: string;
onChange: (value?: string) => void;
context: StandardEditorContext<any>;
suggestions: string[];
}
export class TypeaheadTextField extends React.PureComponent<Props, State> {
constructor(props: Props | Readonly<Props>) {
super(props);
let { value } = props;
if (value === undefined) {
value = props.item.defaultValue;
}
this.state = {
...props,
value: value,
suggestions: [],
};
}
renderSuggestion(suggestion: string) {
return <div>{suggestion}</div>;
}
getColumnNames() {
let { data } = this.props.context;
let series;
let columnNames = [];
if (data !== undefined && data.length > 0) {
series = data[0].fields;
for (const index in series) {
const field = series[index];
const { config, name } = field;
if (config !== undefined && config.displayName !== undefined) {
columnNames.push(config.displayName);
} else {
columnNames.push(name);
}
}
}
return columnNames;
}
onChange = (event: React.FormEvent<HTMLElement>, { newValue }: { newValue: string }) => {
//TODO make this type nicer!
const { path } = this.props.item;
const { value } = event.currentTarget as HTMLInputElement;
this.setState({
value: value,
});
this.props.onChange.call(path, newValue);
};
getSuggestions = (value: string) => {
let inputValue = '';
if (value !== undefined) {
return [];
}
if (value !== undefined && value !== null && value !== '') {
inputValue = value.trim().toLowerCase();
}
const inputLength = inputValue.length;
if (inputLength === 0 || inputValue === undefined) {
return [];
}
return this.getColumnNames().filter((columnName) => columnName.toLowerCase().startsWith(inputValue));
};
onSuggestionsFetchRequested = (value: any) => {
this.setState({
suggestions: this.getSuggestions(value),
});
};
getSuggestionValue = (suggestion: string) => {
return suggestion;
};
onSuggestionsClearRequested = () => {
this.setState({
suggestions: [],
});
};
render() {
let { value } = this.props;
if (value === undefined) {
value = this.props.item.defaultValue;
}
const suggestions = this.getSuggestions(value);
const inputProps: InputProps<string> = {
placeholder: 'Enter column name...',
value,
onChange: this.onChange,
};
return (
<Autosuggest
suggestions={suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
inputProps={inputProps}
theme={{
input: 'input-small gf-form-input width-100',
suggestion: 'suggestion',
}}
/>
);
}
}
================================================
FILE: src/options/dummyDataSwitch/DummyDataSwitch.tsx
================================================
import React from 'react';
import { StandardEditorContext, StandardEditorProps } from '@grafana/data';
import { PanelSettings, DataMapping } from '../../types';
import { Switch } from '@grafana/ui';
interface Props extends StandardEditorProps<boolean, PanelSettings> {
item: any;
value: boolean;
onChange: (value?: boolean) => void;
context: StandardEditorContext<any>;
}
interface State {
item: any;
value: boolean;
dataMapping: DataMapping | undefined;
onChange: (value?: boolean) => void;
context: StandardEditorContext<any>;
}
export class DummyDataSwitch extends React.PureComponent<Props, State> {
constructor(props: Props | Readonly<Props>) {
super(props);
let { dataMapping } = props.context.options;
if (dataMapping === undefined) {
dataMapping = props.item.defaultValue;
}
this.state = {
dataMapping: dataMapping,
...props,
};
}
getDummyDataMapping = () => {
return {
aggregationType: 'service',
sourceColumn: 'origin_service',
targetColumn: 'target_service',
responseTimeColumn: 'in_timesum',
requestRateColumn: 'in_count',
errorRateColumn: 'error_in',
responseTimeOutgoingColumn: 'out_timesum',
requestRateOutgoingColumn: 'out_count',
errorRateOutgoingColumn: 'error_out',
extOrigin: 'origin_external',
extTarget: 'target_external',
type: 'protocol',
showDummyData: true,
baselineRtUpper: 'threshold',
};
};
onChange = () => {
let { dataMapping } = this.props.context.options;
const { item } = this.state;
const { onChange } = this.props;
const newValue = !dataMapping.showDummyData;
if (newValue) {
this.setState({ dataMapping: dataMapping });
dataMapping = this.getDummyDataMapping();
}
dataMapping.showDummyData = newValue;
onChange.call(item.path, dataMapping);
};
render() {
let { dataMapping } = this.props.context.options;
if (dataMapping === undefined) {
dataMapping = this.props.item.defaultValue;
this.props.context.options.dataMapping = this.props.item.defaultValue;
}
return (
<div>
<Switch value={dataMapping.showDummyData} onChange={() => this.onChange()} />
</div>
);
}
}
================================================
FILE: src/options/iconMapping/IconMapping.css
================================================
.service-dependency-graph-panel .no-background {
background-color: transparent;
}
.service-dependency-graph-panel .no-padding-left {
padding-left: 0;
}
.service-dependency-graph-panel .icon-mapping-button {
width: 46.5%;
margin-left: 0;
}
.service-dependency-graph-panel .width-100 {
width: 100%;
}
.service-dependency-graph-panel .width-half {
width: 47%;
text-overflow: ellipsis;
}
================================================
FILE: src/options/iconMapping/IconMapping.tsx
================================================
import React, { ChangeEvent } from 'react';
import { StandardEditorContext, StandardEditorProps } from '@grafana/data';
import { IconResource, PanelSettings } from '../../types';
import assetUtils from '../../panel/asset_utils';
import './IconMapping.css';
interface Props extends StandardEditorProps<string, PanelSettings> {
item: any;
value: any;
onChange: (value?: string) => void;
context: StandardEditorContext<any>;
}
interface State {
item: any;
value: string;
onChange: (value?: string) => void;
context: StandardEditorContext<any>;
icons: string[];
}
export class IconMapping extends React.PureComponent<Props, State> {
constructor(props: Props | Readonly<Props>) {
super(props);
this.state = {
...props,
icons: [],
};
fetch(assetUtils.getAssetUrl('icon_index.json'))
.then((response) => response.json())
.then((data) => {
data.sort();
this.setState({
icons: data,
});
})
.catch(() => {
console.error(
'Could not load service icons mapping index. Please verify the "icon_index.json" in the plugin\'s asset directory.'
);
});
}
addMapping() {
const { path } = this.state.item;
const icons = this.state.context.options[path];
icons.push({ pattern: 'my-type', filename: 'default' });
this.state.onChange.call(path, icons);
}
removeMapping(index: number) {
const { path } = this.state.item;
const icons = this.state.context.options[path];
icons.splice(index, 1);
this.state.onChange.call(path, icons);
}
setPatternValue(event: React.ChangeEvent<HTMLInputElement>, index: number) {
const { path } = this.state.item;
const icons = this.state.context.options[path];
icons[index].pattern = event.currentTarget.value;
this.state.onChange.call(path, icons);
}
setFileNameValue(event: ChangeEvent<HTMLSelectElement>, index: number) {
const { path } = this.state.item;
const icons = this.state.context.options[path];
icons[index].filename = event.currentTarget.value.toString();
this.props.onChange.call(path, icons);
}
render() {
const { path } = this.state.item;
const { icons: iconNames } = this.state;
let icons = this.state.context.options[path];
if (icons === undefined) {
icons = this.state.item.defaultValue;
const context = this.state.context;
context.options[path] = this.state.item.defaultValue;
this.setState({
context: context,
});
}
return (
<div>
<div className="gf-form-inline">
<div className="gf-form width-100">
<label className="gf-form-label no-background no-padding-left width-half">Target Name (RegEx)</label>
<label className="gf-form-label no-background no-padding-left width-half">Icon</label>
</div>
</div>
<div>
{icons.map((icon: IconResource, index: number) => (
<>
<div className="gf-form">
<input
type="text"
className="input-small gf-form-input"
value={icon.pattern}
onChange={(e) => this.setPatternValue(e, index)}
/>
<select
className="input-small gf-form-input"
value={icon.filename}
onChange={(e) => this.setFileNameValue(e, index)}
>
{iconNames.map((iconName: string, index: number) => (
<option key={iconName + '-' + index} value={iconName}>
{iconName}
</option>
))}
</select>
<a className="gf-form-label tight-form-func no-background" onClick={() => this.removeMapping(index)}>
<i className="fa fa-trash"></i>
</a>
</div>
</>
))}
</div>
<button
className="btn navbar-button navbar-button--primary icon-mapping-button"
onClick={() => this.addMapping()}
>
Add Icon Mapping
</button>
</div>
);
}
}
================================================
FILE: src/options/options.tsx
================================================
import { PanelOptionsEditorBuilder } from '@grafana/data';
import { PanelSettings } from '../types';
import { TypeaheadTextField } from './TypeAheadTextfield/TypeaheadTextfield';
import { IconMapping } from './iconMapping/IconMapping';
import { DummyDataSwitch } from './dummyDataSwitch/DummyDataSwitch';
import { DefaultSettings } from './DefaultSettings';
export const optionsBuilder = (builder: PanelOptionsEditorBuilder<PanelSettings>) => {
return (
builder
//Connection Mapping
.addCustomEditor({
path: 'dataMapping.aggregationType',
id: 'aggregationType',
editor: TypeaheadTextField,
name: 'Component Column',
category: ['Connection Mapping'],
defaultValue: DefaultSettings.dataMapping.aggregationType,
})
.addCustomEditor({
path: 'dataMapping.sourceColumn',
id: 'sourceComponentPrefix',
editor: TypeaheadTextField,
name: 'Source Component Column',
category: ['Connection Mapping'],
defaultValue: DefaultSettings.dataMapping.sourceColumn,
})
.addCustomEditor({
path: 'dataMapping.targetColumn',
id: 'targetComponentPrefix',
name: 'Target Component Column',
category: ['Connection Mapping'],
editor: TypeaheadTextField,
defaultValue: DefaultSettings.dataMapping.targetColumn,
})
.addCustomEditor({
path: 'dataMapping.namespaceColumn',
id: 'namespaceColumn',
name: 'Namespace Column',
category: ['Connection Mapping'],
editor: TypeaheadTextField,
defaultValue: DefaultSettings.dataMapping.namespaceColumn,
})
.addCustomEditor({
path: 'dataMapping.nameSpaceDelimiter',
id: 'nameSpaceDelimiter',
name: 'Namespace Delimiter',
category: ['Connection Mapping'],
editor: TypeaheadTextField,
defaultValue: DefaultSettings.dataMapping.namespaceDelimiter,
})
.addCustomEditor({
path: 'dataMapping.type',
id: 'type',
name: 'Type',
category: ['Connection Mapping'],
editor: TypeaheadTextField,
defaultValue: DefaultSettings.dataMapping.type,
})
.addCustomEditor({
path: 'dataMapping.extOrigin',
id: 'externalOrigin',
name: 'External Origin',
category: ['Connection Mapping'],
editor: TypeaheadTextField,
defaultValue: DefaultSettings.dataMapping.extOrigin,
})
.addCustomEditor({
path: 'dataMapping.extTarget',
id: 'externalTarget',
name: 'External Target',
category: ['Connection Mapping'],
editor: TypeaheadTextField,
defaultValue: DefaultSettings.dataMapping.extTarget,
})
//Data Mapping
.addCustomEditor({
id: 'responseTime',
path: 'dataMapping.responseTimeColumn',
name: 'Response Time Column',
editor: TypeaheadTextField,
category: ['Data Mapping'],
defaultValue: DefaultSettings.dataMapping.responseTimeColumn,
})
.addCustomEditor({
id: 'requestRateColumn',
path: 'dataMapping.requestRateColumn',
name: 'Request Rate Column',
editor: TypeaheadTextField,
category: ['Data Mapping'],
defaultValue: DefaultSettings.dataMapping.requestRateColumn,
})
.addCustomEditor({
id: 'errorRateColumn',
path: 'dataMapping.errorRateColumn',
name: 'Error Rate Column',
editor: TypeaheadTextField,
category: ['Data Mapping'],
defaultValue: DefaultSettings.dataMapping.errorRateColumn,
})
.addCustomEditor({
id: 'responseTimeOutgoingColumn',
path: 'dataMapping.responseTimeOutgoingColumn',
name: 'Response Time Column (Outgoing)',
editor: TypeaheadTextField,
category: ['Data Mapping'],
defaultValue: DefaultSettings.dataMapping.responseTimeOutgoingColumn,
})
.addCustomEditor({
id: 'requestRateOutgoingColumn',
path: 'dataMapping.requestRateOutgoingColumn',
name: 'Request Rate Column (Outgoing)',
editor: TypeaheadTextField,
category: ['Data Mapping'],
defaultValue: DefaultSettings.dataMapping.requestRateOutgoingColumn,
})
.addCustomEditor({
id: 'errorRateOutgoingColumn',
path: 'dataMapping.errorRateOutgoingColumn',
name: 'Error Rate Column (Outgoing)',
editor: TypeaheadTextField,
category: ['Data Mapping'],
defaultValue: DefaultSettings.dataMapping.errorRateOutgoingColumn,
})
.addCustomEditor({
id: 'baselineRtUpper',
path: 'dataMapping.baselineRtUpper',
name: 'Response Time Baseline (Upper)',
editor: TypeaheadTextField,
category: ['Data Mapping'],
defaultValue: DefaultSettings.dataMapping.baselineRtUpper,
})
//General Settings
.addBooleanSwitch({
path: 'showConnectionStats',
name: 'Show Connection Statistics',
category: ['General Settings'],
defaultValue: DefaultSettings.showConnectionStats,
})
.addBooleanSwitch({
path: 'sumTimings',
name: 'Handle Timings as Sums',
description:
'If this setting is active, the timings provided' +
'by the mapped response time columns are considered as a ' +
'continually increasing sum of response times. When ' +
'deactivated, it is considered that the timings provided ' +
'by columns are the actual average response times.',
category: ['General Settings'],
defaultValue: DefaultSettings.sumTimings,
})
.addBooleanSwitch({
path: 'filterEmptyConnections',
name: 'Filter Empty Data',
description:
'If this setting is active, the timings provided by ' +
'the mapped response time columns are considered as a continually ' +
'increasing sum of response times. When deactivated, it is considered ' +
'that the timings provided by columns are the actual average response times.',
category: ['General Settings'],
defaultValue: DefaultSettings.filterEmptyConnections,
})
.addBooleanSwitch({
path: 'showDebugInformation',
name: 'Show Debug Information',
category: ['General Settings'],
defaultValue: DefaultSettings.showDebugInformation,
})
.addCustomEditor({
path: 'dataMapping',
id: 'dummyDataSwitch',
name: 'Show Dummy Data',
editor: DummyDataSwitch,
category: ['General Settings'],
defaultValue: DefaultSettings.dataMapping,
})
.addBooleanSwitch({
path: 'showBaselines',
name: 'Show Baselines',
category: ['General Settings'],
defaultValue: DefaultSettings.showBaselines,
})
.addSelect({
path: 'timeFormat',
name: 'Maximum Time Unit to Resolve',
description:
'This setting controls to which time unit time values will be resolved to. ' +
'Each value always includes the smaller units.',
category: ['General Settings'],
settings: {
options: [
{ value: 'ms', label: 'ms' },
{ value: 's', label: 's' },
{ value: 'm', label: 'm' },
],
},
defaultValue: DefaultSettings.timeFormat,
})
//Appearance
.addColorPicker({
path: 'style.healthyColor',
name: 'Healthy Color',
category: ['Appearance'],
defaultValue: DefaultSettings.style.healthyColor,
})
.addColorPicker({
path: 'style.dangerColor',
name: 'Danger Color',
category: ['Appearance'],
defaultValue: DefaultSettings.style.dangerColor,
})
.addColorPicker({
path: 'style.noDataColor',
name: 'No Data Color',
category: ['Appearance'],
defaultValue: DefaultSettings.style.noDataColor,
})
//Icon Mapping
.addCustomEditor({
path: 'icons',
id: 'iconMapping',
editor: IconMapping,
name: '',
description:
'This setting controls which images should be mapped to your directly monitored nodes. ' +
'The node names are matched by the regex pattern provided in the "Target Name(Regex) column.',
category: ['Icon Mapping'],
defaultValue: DefaultSettings.icons,
})
//External Icon Mapping
.addCustomEditor({
path: 'externalIcons',
id: 'externalIconMapping',
editor: IconMapping,
name: '',
description:
'This setting controls which images should be mapped to the external nodes. ' +
'The given type column is matched by the regex pattern provided in the "Target Name(Regex) column.',
category: ['External Icon Mapping'],
defaultValue: DefaultSettings.externalIcons,
})
//Tracing Drilldown
.addTextInput({
path: 'drillDownLink',
name: 'Backend URL',
category: ['Tracing Drilldown'],
defaultValue: DefaultSettings.drillDownLink,
})
);
};
================================================
FILE: src/panel/PanelController.tsx
================================================
import React, { LegacyRef, PureComponent } from 'react';
import {
AbsoluteTimeRange,
DataFrame,
FieldConfigSource,
InterpolateFunction,
PanelProps,
TimeRange,
} from '@grafana/data';
import { ServiceDependencyGraph } from './serviceDependencyGraph/ServiceDependencyGraph';
import _ from 'lodash';
import { CurrentData, CyData, IntGraph, IntGraphEdge, IntGraphNode, PanelSettings } from '../types';
import cytoscape, { EdgeSingular, NodeSingular } from 'cytoscape';
import '../css/novatec-service-dependency-graph-panel.css';
import GraphGenerator from 'processing/graph_generator';
import PreProcessor from 'processing/pre_processor';
import data from '../dummy_data_frame';
import { getTemplateSrv } from '@grafana/runtime';
interface Props extends PanelProps<PanelSettings> {}
interface PanelState {
id: string | number;
fieldConfig: FieldConfigSource<any>;
height: number;
width: number;
onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void;
onFieldConfigChange: (config: FieldConfigSource<any>) => void;
onOptionsChange: (options: PanelSettings) => void;
renderCounter: number;
replaceVariables: InterpolateFunction;
timeRange: TimeRange;
timeZone: string;
title: string;
transparent: boolean;
options: PanelSettings;
currentLayer: number;
}
export class PanelController extends PureComponent<Props, PanelState> {
cy: cytoscape.Core | undefined;
ref: LegacyRef<HTMLDivElement>;
validQueryTypes: boolean;
graphGenerator: GraphGenerator;
preProcessor: PreProcessor;
currentData: CurrentData;
maxLayer = 0;
constructor(props: Props) {
super(props);
this.state = {
currentLayer: 0,
...props,
};
this.ref = React.createRef();
this.graphGenerator = new GraphGenerator(this);
this.preProcessor = new PreProcessor(this);
}
getSettings(resolveVariables: boolean): PanelSettings {
if (resolveVariables) {
return this.resolveVariables(this.props.options);
}
return this.props.options;
}
resolveVariables(element: any) {
if (element instanceof Object) {
const newObject: any = {};
for (const key of Object.keys(element)) {
newObject[key] = this.resolveVariables(element[key]);
}
return newObject;
}
if (element instanceof String || typeof element === 'string') {
return getTemplateSrv().replace(element.toString());
}
return element;
}
resolveTemplateVars(input: any, copy: boolean) {
let value = input;
if (copy) {
value = _.cloneDeep(value);
}
if (typeof value === 'string' || value instanceof String) {
value = getTemplateSrv().replace(value.toString());
}
if (value instanceof Object) {
for (const key of Object.keys(value)) {
value[key] = this.resolveTemplateVars(value[key], false);
}
}
return value;
}
componentDidUpdate() {
this.processData();
}
processQueryData(data: DataFrame[]) {
this.validQueryTypes = this.hasOnlyTableQueries(data);
const graphData = this.preProcessor.processData(data);
this.currentData = graphData;
}
hasOnlyTableQueries(inputData: DataFrame[]) {
let result = true;
_.each(inputData, (dataElement) => {
if (!_.has(dataElement, 'columns')) {
result = false;
}
});
return result;
}
processData() {
let inputData: DataFrame[] = this.props.data.series;
if (this.getSettings(true).dataMapping.showDummyData) {
inputData = data;
}
this.processQueryData(inputData);
const graph: IntGraph = this.graphGenerator.generateGraph(this.currentData.graph);
return graph;
}
_transformEdges(edges: IntGraphEdge[]): CyData[] {
const cyEdges = _.map(edges, (edge) => {
const cyEdge = {
group: 'edges',
data: {
id: edge.source + ':' + edge.target,
source: edge.source,
target: edge.target,
metrics: {
...edge.metrics,
},
},
};
return cyEdge;
});
return cyEdges;
}
_transformNodes(nodes: IntGraphNode[]): CyData[] {
const cyNodes = _.map(nodes, (node) => {
const result: CyData = {
group: 'nodes',
data: {
id: node.data.id,
type: node.data.type,
external_type: node.data.external_type,
namespace: node.data.namespace,
layer: node.data.layer,
parent: node.data.parent,
metrics: {
...node.data.metrics,
},
},
};
return result;
});
return cyNodes;
}
_updateOrRemove(dataArray: Array<NodeSingular | EdgeSingular>, inputArray: CyData[]) {
const elements: Array<NodeSingular | EdgeSingular> = [];
for (let i = 0; i < dataArray.length; i++) {
const element = dataArray[i];
const cyNode = _.find(inputArray, { data: { id: element.id() } });
if (cyNode) {
element.data(cyNode.data);
_.remove(inputArray, (n) => n.data.id === cyNode.data.id);
elements.push(element);
} else {
element.remove();
}
}
return elements;
}
getError(): string | null {
if (!this.isDataAvailable()) {
return 'No data to show - the query returned no data.';
}
return null;
}
isDataAvailable() {
const dataExist =
!_.isUndefined(this.currentData) && !_.isUndefined(this.currentData.graph) && this.currentData.graph.length > 0;
return dataExist;
}
layer(layerIncrease: number) {
const that = this;
const currentLayer = that.state ? that.state.currentLayer : 0;
let layer = Math.max(0, currentLayer + layerIncrease);
if (layerIncrease > 0) {
layer = Math.min(that.maxLayer, currentLayer + layerIncrease);
}
that.setState({
currentLayer: layer,
});
}
render() {
const data = this.processData();
const error = this.getError();
if (error === null) {
// This is our root DOM
return (
<div>
<div
// The className is also used to scope all of our css rules
className="service-dependency-graph-panel"
style={{ height: this.props.height, width: this.props.width }}
ref={this.ref}
id="cy"
>
<ServiceDependencyGraph
data={data}
zoom={1}
maxLayer={this.maxLayer}
controller={this}
animate={false}
showStatistics={false}
settings={this.props.options}
layerIncreaseFunction={() => this.layer(+1)}
layerDecreaseFunction={() => this.layer(-1)}
layer={0}
/>
</div>
</div>
);
} else {
return <div>{error}</div>;
}
}
}
================================================
FILE: src/panel/asset_utils.tsx
================================================
import { find } from 'lodash';
import { IconResource } from 'types';
export default {
getAssetUrl(assetName: string) {
let baseUrl = 'public/plugins/novatec-sdg-panel';
return baseUrl + '/assets/icons/' + assetName;
},
getTypeSymbol(type: string, externalIcons: IconResource[], resolveName = true) {
if (!type) {
return this.getAssetUrl('default.png');
}
if (!resolveName) {
return this.getAssetUrl(type);
}
const icon = find(externalIcons, (icon) => icon.pattern.toLowerCase() === type.toLowerCase());
if (icon !== undefined) {
return this.getAssetUrl(icon.filename + '.png');
} else {
return this.getAssetUrl('default.png');
}
},
};
================================================
FILE: src/panel/canvas/collision_detector.ts
================================================
import _ from 'lodash';
import { Point, Rectangle } from 'types';
export default class CollisionDetector {
blockedArea: Rectangle[];
constructor() {
this.blockedArea = [];
}
reset() {
this.blockedArea = [];
}
addRectangle(x: number, y: number, width: number, height: number) {
const rectangle: Rectangle = {
coordinates: {
x: x,
y: y,
},
height: height,
width: width,
};
this.blockedArea.push(rectangle);
}
isColliding(shape: Rectangle) {
const collidingShape = this.blockedArea.find((blockingShape) => {
if (this._intersects(shape, blockingShape)) {
return true;
}
return false;
});
return collidingShape !== undefined;
}
_intersects(a: Rectangle, b: Rectangle) {
const topLeft1: Point = a.coordinates;
const topLeft2: Point = b.coordinates;
const bottomRight1 = this._getBottomRightCorner(a);
const bottomRight2 = this._getBottomRightCorner(b);
if (topLeft1.x > bottomRight2.x || topLeft2.x > bottomRight1.x) {
return false;
}
if (topLeft1.y > bottomRight2.y || topLeft2.y > bottomRight1.y) {
return false;
}
return true;
}
_getBottomRightCorner(rectangle: Rectangle) {
const cornerPoint: Point = {
x: rectangle.coordinates.x + rectangle.width,
y: rectangle.coordinates.y + rectangle.height,
};
return cornerPoint;
}
}
================================================
FILE: src/panel/canvas/graph_canvas.ts
================================================
import _ from 'lodash';
import cytoscape from 'cytoscape';
import { ServiceDependencyGraph } from '../serviceDependencyGraph/ServiceDependencyGraph';
import ParticleEngine from './particle_engine';
import {
CyCanvas,
Particle,
EnGraphNodeType,
Particles,
IntGraphMetrics,
ScaleValue,
DrawContext,
Rectangle,
Point,
} from '../../types';
import humanFormat from 'human-format';
import assetUtils from '../asset_utils';
import CollisionDetector from './collision_detector';
const scaleValues: ScaleValue[] = [
{ unit: 'ms', factor: 1 },
{ unit: 's', factor: 1000 },
{ unit: 'm', factor: 60000 },
];
export default class CanvasDrawer {
readonly colors = {
default: '#bad5ed',
background: '#212121',
edge: '#505050',
status: {
warning: 'orange',
},
};
readonly donutRadius: number = 15;
controller: ServiceDependencyGraph;
cytoscape: cytoscape.Core;
context: CanvasRenderingContext2D;
cyCanvas: CyCanvas;
canvas: HTMLCanvasElement;
offscreenCanvas: HTMLCanvasElement;
offscreenContext: CanvasRenderingContext2D;
frameCounter = 0;
fpsCounter = 0;
particleImage: HTMLImageElement;
pixelRatio: number;
imageAssets: any = {};
selectionNeighborhood: cytoscape.Collection;
particleEngine: ParticleEngine;
collisionDetector: CollisionDetector;
lastRenderTime = 0;
dashAnimationOffset = 0;
constructor(ctrl: ServiceDependencyGraph, cy: cytoscape.Core, cyCanvas: CyCanvas) {
this.cytoscape = cy;
this.cyCanvas = cyCanvas;
this.controller = ctrl;
this.particleEngine = new ParticleEngine(this);
this.collisionDetector = new CollisionDetector();
this.pixelRatio = window.devicePixelRatio || 1;
this.canvas = cyCanvas.getCanvas();
const ctx = this.canvas.getContext('2d');
if (ctx) {
this.context = ctx;
} else {
console.error('Could not get 2d canvas context.');
}
this.offscreenCanvas = document.createElement('canvas');
this.offscreenContext = this.offscreenCanvas.getContext('2d');
this.repaint(true);
}
_getTimeScale(timeUnit: string) {
const scale: any = {};
for (const scaleValue of scaleValues) {
scale[scaleValue.unit] = scaleValue.factor;
if (scaleValue.unit === timeUnit) {
return scale;
}
}
return scale;
}
resetAssets() {
this.imageAssets = {};
}
_loadImage(imageUrl: string, assetName: string) {
const that = this;
const loadImage = (url: string, asset: keyof typeof that.imageAssets) => {
const image = new Image();
that.imageAssets[asset] = {
image,
loaded: false,
};
return new Promise((resolve, reject) => {
image.onload = () => resolve(asset);
image.onerror = () => reject(new Error(`load ${url} fail`));
image.src = url;
});
};
loadImage(imageUrl, assetName).then((asset: any) => {
that.imageAssets[asset].loaded = true;
});
}
_isImageLoaded(assetName: string) {
if (_.has(this.imageAssets, assetName) && this.imageAssets[assetName].loaded) {
return true;
} else {
return false;
}
}
_getImageAsset(assetName: string, resolveName = true) {
if (!_.has(this.imageAssets, assetName)) {
const { externalIcons } = this.controller.getSettings(true);
const assetUrl = assetUtils.getTypeSymbol(assetName, externalIcons, resolveName);
this._loadImage(assetUrl, assetName);
}
if (this._isImageLoaded(assetName)) {
return this.imageAssets[assetName].image;
} else {
return null;
}
}
_getAsset(assetName: string, relativeUrl: string) {
if (!_.has(this.imageAssets, assetName)) {
const assetUrl = assetUtils.getAssetUrl(relativeUrl);
this._loadImage(assetUrl, assetName);
}
if (this._isImageLoaded(assetName)) {
return this.imageAssets[assetName].image;
} else {
return null;
}
}
start() {
const that = this;
const repaintWrapper = () => {
that.repaint();
window.requestAnimationFrame(repaintWrapper);
};
window.requestAnimationFrame(repaintWrapper);
setInterval(() => {
that.fpsCounter = that.frameCounter;
that.frameCounter = 0;
}, 1000);
}
startAnimation() {
this.particleEngine.start();
}
stopAnimation() {
this.particleEngine.stop();
this.repaint();
}
_skipFrame() {
const now = Date.now();
const elapsedTime = now - this.lastRenderTime;
if (this.particleEngine.count() > 0) {
return false;
}
if (!this.controller.getSettings(true).animate && elapsedTime < 1000) {
return true;
}
return false;
}
repaint(forceRepaint = false) {
if (!forceRepaint && this._skipFrame()) {
return;
}
this.lastRenderTime = Date.now();
const ctx = this.context;
const cyCanvas = this.cyCanvas;
const offscreenCanvas = this.offscreenCanvas;
const offscreenContext = this.offscreenContext;
this.collisionDetector.reset();
offscreenCanvas.width = this.canvas.width;
offscreenCanvas.height = this.canvas.height;
// offscreen rendering
this._setTransformation(offscreenContext);
this.selectionNeighborhood = this.cytoscape.collection();
const selection = this.cytoscape.$(':selected');
selection.forEach((element: cytoscape.SingularElementArgument) => {
this.selectionNeighborhood.merge(element);
if (element.isNode()) {
const neighborhood = element.neighborhood();
this.selectionNeighborhood.merge(neighborhood);
} else {
const source = element.source();
const target = element.target();
this.selectionNeighborhood.merge(source);
this.selectionNeighborhood.merge(target);
}
});
this._drawEdgeAnimation(offscreenContext);
this._drawNodes(offscreenContext);
// static element rendering
// cyCanvas.resetTransform(ctx);
cyCanvas.clear(ctx);
if (this.controller.getSettings(true).showDebugInformation) {
this._drawDebugInformation();
}
if (offscreenCanvas.width > 0 && offscreenCanvas.height > 0) {
ctx.drawImage(offscreenCanvas, 0, 0);
}
// baseline animation
this.dashAnimationOffset = (Date.now() % 60000) / 250;
}
_setTransformation(ctx: CanvasRenderingContext2D) {
const pan = this.cytoscape.pan();
const zoom = this.cytoscape.zoom();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.translate(pan.x * this.pixelRatio, pan.y * this.pixelRatio);
ctx.scale(zoom * this.pixelRatio, zoom * this.pixelRatio);
}
_drawEdgeAnimation(ctx: CanvasRenderingContext2D) {
const now = Date.now();
ctx.save();
const edges = this.cytoscape.edges().toArray();
const hasSelection = this.selectionNeighborhood.size() > 0;
const transparentEdges = edges.filter((edge) => hasSelection && !this.selectionNeighborhood.has(edge));
const opaqueEdges = edges.filter((edge) => !hasSelection || this.selectionNeighborhood.has(edge));
ctx.globalAlpha = 0.25;
this._drawEdges(ctx, transparentEdges, now);
ctx.globalAlpha = 1;
this._drawEdges(ctx, opaqueEdges, now);
ctx.restore();
}
_drawEdges(ctx: CanvasRenderingContext2D, edges: cytoscape.EdgeSingular[], now: number) {
const cy = this.cytoscape;
for (const edge of edges) {
const sourcePoint = edge.sourceEndpoint();
const targetPoint = edge.targetEndpoint();
this._drawEdgeLine(ctx, edge, sourcePoint, targetPoint);
this._drawEdgeParticles(ctx, edge, sourcePoint, targetPoint, now);
}
const { showConnectionStats } = this.controller.getSettings(true);
if (showConnectionStats && cy.zoom() > 1) {
for (const edge of edges) {
this._drawEdgeLabel(ctx, edge);
}
}
}
_drawEdgeLine(
ctx: CanvasRenderingContext2D,
edge: cytoscape.EdgeSingular,
sourcePoint: cytoscape.Position,
targetPoint: cytoscape.Position
) {
ctx.beginPath();
ctx.moveTo(sourcePoint.x, sourcePoint.y);
ctx.lineTo(targetPoint.x, targetPoint.y);
const metrics = edge.data('metrics');
const requestCount = _.get(metrics, 'normal', -1);
const errorCount = _.get(metrics, 'danger', -1);
let base;
if (!this.selectionNeighborhood.empty() && this.selectionNeighborhood.has(edge)) {
ctx.lineWidth = 3;
base = 140;
} else {
ctx.lineWidth = 1;
base = 80;
}
if (requestCount >= 0 && errorCount >= 0) {
const range = 255;
const factor = errorCount / requestCount;
const color = Math.min(255, base + range * Math.log2(factor + 1));
ctx.strokeStyle = 'rgb(' + color + ',' + base + ',' + base + ')';
} else {
ctx.strokeStyle = 'rgb(' + base + ',' + base + ',' + base + ')';
}
ctx.stroke();
}
_drawEdgeLabel(ctx: CanvasRenderingContext2D, edge: cytoscape.EdgeSingular) {
const { timeFormat } = this.controller.getSettings(true);
const midpoint = edge.midpoint();
const xMid = midpoint.x;
const yMid = midpoint.y;
let statistics: string[] = [];
const metrics: IntGraphMetrics = edge.data('metrics');
const duration = _.defaultTo(metrics.response_time, -1);
const requestCount = _.defaultTo(metrics.rate, -1);
const errorCount = _.defaultTo(metrics.error_rate, -1);
const timeScale = new humanFormat.Scale(this._getTimeScale(timeFormat));
if (duration >= 0) {
const decimals = duration >= 1000 ? 1 : 0;
statistics.push(humanFormat(duration, { scale: timeScale, decimals }));
}
if (requestCount >= 0) {
const decimals = requestCount >= 1000 ? 1 : 0;
statistics.push(humanFormat(parseFloat(requestCount.toString()), { decimals }) + ' Req.');
}
if (errorCount >= 0) {
const decimals = errorCount >= 1000 ? 1 : 0;
statistics.push(humanFormat(errorCount, { decimals }) + ' Err.');
}
if (statistics.length > 0) {
const edgeLabel = statistics.join(', ');
this._drawLabel(ctx, edgeLabel, xMid, yMid, edge);
}
}
_drawEdgeParticles(
ctx: CanvasRenderingContext2D,
edge: cytoscape.EdgeSingular,
sourcePoint: cytoscape.Position,
targetPoint: cytoscape.Position,
now: number
) {
const particles: Particles = edge.data('particles');
if (particles === undefined) {
return;
}
const xVector = targetPoint.x - sourcePoint.x;
const yVector = targetPoint.y - sourcePoint.y;
const angle = Math.atan2(yVector, xVector);
const xDirection = Math.cos(angle);
const yDirection = Math.sin(angle);
const xMinLimit = Math.min(sourcePoint.x, targetPoint.x);
const xMaxLimit = Math.max(sourcePoint.x, targetPoint.x);
const yMinLimit = Math.min(sourcePoint.y, targetPoint.y);
const yMaxLimit = Math.max(sourcePoint.y, targetPoint.y);
const drawContext: DrawContext = {
ctx,
now,
xDirection,
yDirection,
xMinLimit,
xMaxLimit,
yMinLimit,
yMaxLimit,
sourcePoint,
};
// normal particles
ctx.beginPath();
let index = particles.normal.length - 1;
while (index >= 0) {
this._drawParticle(drawContext, particles.normal, index);
index--;
}
ctx.fillStyle = '#d1e2f2';
ctx.fill();
// danger particles
ctx.beginPath();
index = particles.danger.length - 1;
while (index >= 0) {
this._drawParticle(drawContext, particles.danger, index);
index--;
}
const dangerColor = this.controller.getSettings(true).style.dangerColor;
ctx.fillStyle = dangerColor;
ctx.fill();
}
_drawLabel(ctx: CanvasRenderingContext2D, label: string, cX: number, cY: number, edge: cytoscape.EdgeSingular) {
const labelPadding = 1;
ctx.font = '6px Arial';
const labelWidth = ctx.measureText(label).width;
let xPos = cX - labelWidth / 2;
let yPos = cY + 3;
let labelArea: Rectangle = {
coordinates: {
x: xPos - labelPadding + 4,
y: yPos - 6 - labelPadding + 4,
},
width: labelWidth,
height: 6,
};
if (!isNaN(labelArea.coordinates.x) || !isNaN(labelArea.coordinates.y) || !isNaN(xPos) || !isNaN(yPos)) {
// TODO: Rather than using a fixed number here, we should find a way to compute a second boundary condition smarter.
// This is for the case when all nodes are so close together the labels need to overlap each other.
const maxRepeats = 1000;
let repeats = 0;
while (this.collisionDetector.isColliding(labelArea) && repeats < maxRepeats) {
const nextPoint = this._getNextPointOnVector(xPos, yPos, edge, 0.999);
labelArea.coordinates = nextPoint;
yPos = nextPoint.y;
xPos = nextPoint.x;
repeats++;
}
this.collisionDetector.addRectangle(xPos - 4, yPos - 4, labelWidth + 12, 12);
}
ctx.fillStyle = this.colors.default;
ctx.fillRect(xPos - labelPadding, yPos - 6 - labelPadding, labelWidth + 2 * labelPadding, 6 + 2 * labelPadding);
ctx.fillStyle = this.colors.background;
ctx.fillText(label, xPos, yPos);
}
_getNextPointOnVector(x: number, y: number, edge: cytoscape.EdgeSingular, step: number) {
let yTarget = edge.sourceEndpoint().y;
let xTarget = edge.sourceEndpoint().x;
const newPoint: Point = {
x: xTarget * (1.0 - step) + x * step,
y: yTarget * (1.0 - step) + y * step,
};
return newPoint;
}
_drawParticle(drawCtx: DrawContext, particles: Particle[], index: number) {
const { ctx, now, xDirection, yDirection, xMinLimit, xMaxLimit, yMinLimit, yMaxLimit, sourcePoint } = drawCtx;
const particle = particles[index];
const timeDelta = now - particle.startTime;
const xPos = sourcePoint.x + xDirection * timeDelta * particle.velocity;
const yPos = sourcePoint.y + yDirection * timeDelta * particle.velocity;
if (xPos > xMaxLimit || xPos < xMinLimit || yPos > yMaxLimit || yPos < yMinLimit) {
// remove particle
particles.splice(index, 1);
} else {
// draw particle
ctx.moveTo(xPos, yPos);
ctx.arc(xPos, yPos, 1, 0, 2 * Math.PI, false);
}
}
_drawNodes(ctx: CanvasRenderingContext2D) {
const that = this;
const cy = this.cytoscape;
// Draw model elements
const nodes = cy.nodes().toArray();
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (that.selectionNeighborhood.empty() || that.selectionNeighborhood.has(node)) {
ctx.globalAlpha = 1;
} else {
ctx.globalAlpha = 0.25;
}
// draw the node
if (node.data().type === 'PARENT') {
if (
node.data().layer >= this.controller.state.controller.state.currentLayer ||
node.data().layer === undefined
) {
that._drawNode(ctx, node);
}
} else {
that._drawNode(ctx, node);
}
// drawing the node label in case we are not zoomed out
if (cy.zoom() > 1) {
that._drawNodeLabel(ctx, node);
}
}
}
_drawNode(ctx: CanvasRenderingContext2D, node: cytoscape.NodeSingular) {
const cy = this.cytoscape;
const type = node.data('type');
const metrics: IntGraphMetrics = node.data('metrics');
if (type === EnGraphNodeType.INTERNAL) {
const requestCount = _.defaultTo(metrics.rate, -1);
const errorCount = _.defaultTo(metrics.error_rate, 0);
const responseTime = _.defaultTo(metrics.response_time, -1);
const threshold = _.defaultTo(metrics.threshold, -1);
let unknownPct;
let errorPct;
let healthyPct;
if (requestCount < 0) {
healthyPct = 0;
errorPct = 0;
unknownPct = 1;
} else {
if (errorCount <= 0) {
errorPct = 0.0;
} else {
errorPct = (1.0 / requestCount) * errorCount;
}
healthyPct = 1.0 - errorPct;
unknownPct = 0;
}
// drawing the donut
this._drawDonut(ctx, node, 15, 5, 0.5, [errorPct, unknownPct, healthyPct]);
// drawing the baseline status
const { showBaselines } = this.controller.getSettings(true);
if (showBaselines && responseTime >= 0 && threshold >= 0) {
const thresholdViolation = threshold < responseTime;
this._drawThresholdStroke(ctx, node, thresholdViolation, 15, 5, 0.5);
}
this._drawServiceIcon(ctx, node);
} else {
this._drawExternalService(ctx, node);
}
// draw statistics
if (cy.zoom() > 1) {
this._drawNodeStatistics(ctx, node);
}
}
_drawServiceIcon(ctx: CanvasRenderingContext2D, node: cytoscape.NodeSingular) {
const nodeId: string = node.id();
const iconMappings = this.controller.getSettings(true).icons;
const mapping = _.find(iconMappings, ({ pattern }) => {
try {
return new RegExp(pattern).test(nodeId);
} catch (error) {
return false;
}
});
if (mapping) {
const image = this._getAsset(mapping.filename, mapping.filename + '.png');
if (image != null) {
const cX = node.position().x;
const cY = node.position().y;
const iconSize = 16;
ctx.drawImage(image, cX - iconSize / 2, cY - iconSize / 2, iconSize, iconSize);
}
}
}
_drawNodeStatistics(ctx: CanvasRenderingContext2D, node: cytoscape.NodeSingular) {
const { timeFormat } = this.controller.getSettings(true);
const lines: string[] = [];
const metrics: IntGraphMetrics = node.data('metrics');
const requestCount = _.defaultTo(metrics.rate, -1);
const errorCount = _.defaultTo(metrics.error_rate, -1);
const responseTime = _.defaultTo(metrics.response_time, -1);
const timeScale = new humanFormat.Scale(this._getTimeScale(timeFormat));
if (requestCount >= 0) {
const decimals = requestCount >= 1000 ? 1 : 0;
lines.push('Requests: ' + humanFormat(parseFloat(requestCount.toString()), { decimals }));
}
if (errorCount >= 0) {
const decimals = errorCount >= 1000 ? 1 : 0;
lines.push('Errors: ' + humanFormat(errorCount, { decimals }));
}
if (responseTime >= 0) {
const decimals = responseTime >= 1000 ? 1 : 0;
lines.push('Avg. Resp. Time: ' + humanFormat(responseTime, { scale: timeScale, decimals }));
}
const pos = node.position();
const fontSize = 6;
const cX = pos.x + this.donutRadius * 1.25;
const cY = pos.y + fontSize / 2 - (fontSize / 2) * (lines.length - 1);
ctx.font = '6px Arial';
ctx.fillStyle = this.colors.default;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], cX, cY + i * fontSize);
}
}
_drawThresholdStroke(
ctx: CanvasRenderingContext2D,
node: cytoscape.NodeSingular,
violation: boolean,
radius: number,
width: number,
baseStrokeWidth: number
) {
const pos = node.position();
const cX = pos.x;
const cY = pos.y;
const strokeWidth = baseStrokeWidth * 2 * (violation ? 1.5 : 1);
const offset = strokeWidth * 0.2;
ctx.beginPath();
ctx.arc(cX, cY, radius + strokeWidth - offset, 0, 2 * Math.PI, false);
ctx.closePath();
ctx.setLineDash([]);
ctx.lineWidth = strokeWidth * 1;
ctx.strokeStyle = 'white';
ctx.stroke();
ctx.beginPath();
ctx.arc(cX, cY, radius + strokeWidth - offset, 0, 2 * Math.PI, false);
ctx.closePath();
ctx.setLineDash([10, 2]);
if (violation && this.controller.getSettings(true).animate) {
ctx.lineDashOffset = this.dashAnimationOffset;
} else {
ctx.lineDashOffset = 0;
}
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = violation ? 'rgb(184, 36, 36)' : '#37872d';
ctx.stroke();
// inner
ctx.beginPath();
ctx.arc(cX, cY, radius - width - baseStrokeWidth, 0, 2 * Math.PI, false);
ctx.closePath();
ctx.fillStyle = violation ? 'rgb(184, 36, 36)' : '#37872d';
ctx.fill();
}
_drawExternalService(ctx: CanvasRenderingContext2D, node: cytoscape.NodeSingular) {
const pos = node.position();
const cX = pos.x;
const cY = pos.y;
const size = 12;
ctx.beginPath();
ctx.arc(cX, cY, 12, 0, 2 * Math.PI, false);
ctx.fillStyle = 'white';
ctx.fill();
ctx.beginPath();
ctx.arc(cX, cY, 11.5, 0, 2 * Math.PI, false);
ctx.fillStyle = this.colors.background;
ctx.fill();
const nodeType = node.data('external_type');
const image = this._getImageAsset(nodeType);
if (image != null) {
ctx.drawImage(image, cX - size / 2, cY - size / 2, size, size);
}
}
_drawNodeLabel(ctx: CanvasRenderingContext2D, node: cytoscape.NodeSingular) {
const pos = node.position();
let label: string = node.id();
const labelPadding = 1;
if (this.selectionNeighborhood.empty() || !this.selectionNeighborhood.has(node)) {
if (label.length > 20) {
label = label.substr(0, 7) + '...' + label.slice(-7);
}
}
ctx.font = '6px Arial';
const labelWidth = ctx.measureText(label).width;
const xPos = pos.x - labelWidth / 2;
let yPos = pos.y + node.height() * 0.8;
if (node.data().type === 'PARENT') {
if (node.data().layer >= this.controller.state.controller.state.currentLayer || node.data().layer === undefined) {
} else {
yPos = pos.y + node.height() * 0.5 + 30;
}
}
const { showBaselines } = this.controller.getSettings(true);
const metrics: IntGraphMetrics = node.data('metrics');
const responseTime = _.defaultTo(metrics.response_time, -1);
const threshold = _.defaultTo(metrics.threshold, -1);
if (!showBaselines || threshold < 0 || responseTime < 0 || responseTime <= threshold) {
ctx.fillStyle = this.colors.default;
} else {
ctx.fillStyle = '#FF7383';
}
ctx.fillRect(xPos - labelPadding, yPos - 6 - labelPadding, labelWidth + 2 * labelPadding, 6 + 2 * labelPadding);
ctx.fillStyle = this.colors.background;
ctx.fillText(label, xPos, yPos);
}
_drawDebugInformation() {
const ctx = this.context;
this.frameCounter++;
ctx.font = '12px monospace';
ctx.fillStyle = 'white';
ctx.fillText('Frames per Second: ' + this.fpsCounter, 10, 12);
ctx.fillText('Particles: ' + this.particleEngine.count(), 10, 24);
}
_drawDonut(
ctx: CanvasRenderingContext2D,
node: cytoscape.NodeSingular,
radius: number,
width: number,
strokeWidth: number,
percentages: number[]
) {
const cX = node.position().x;
const cY = node.position().y;
let currentArc = -Math.PI / 2; // offset
ctx.beginPath();
ctx.arc(cX, cY, radius + strokeWidth, 0, 2 * Math.PI, false);
ctx.closePath();
ctx.fillStyle = 'white';
ctx.fill();
const { healthyColor, dangerColor, noDataColor } = this.controller.getSettings(true).style;
const colors = [dangerColor, noDataColor, healthyColor];
for (let i = 0; i < percentages.length; i++) {
let arc = this._drawArc(ctx, currentArc, cX, cY, radius, percentages[i], colors[i]);
currentArc += arc;
}
ctx.beginPath();
ctx.arc(cX, cY, radius - width, 0, 2 * Math.PI, false);
ctx.fillStyle = 'white';
ctx.fill();
// cut out an inner-circle == donut
ctx.beginPath();
ctx.arc(cX, cY, radius - width - strokeWidth, 0, 2 * Math.PI, false);
if (node.selected()) {
ctx.fillStyle = 'white';
} else {
ctx.fillStyle = this.colors.background;
}
ctx.fill();
}
_drawArc(
ctx: CanvasRenderingContext2D,
currentArc: number,
cX: number,
cY: number,
radius: number,
percent: number,
color: string
) {
// calc size of our wedge in radians
let WedgeInRadians = (percent * 360 * Math.PI) / 180;
// draw the wedge
ctx.save();
ctx.beginPath();
ctx.moveTo(cX, cY);
ctx.arc(cX, cY, radius, currentArc, currentArc + WedgeInRadians, false);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
// sum the size of all wedges so far
// We will begin our next wedge at this sum
return WedgeInRadians;
}
}
================================================
FILE: src/panel/canvas/particle_engine.ts
================================================
import CanvasDrawer from './graph_canvas';
import _ from 'lodash';
import { Particles, Particle, IntGraphMetrics } from '../../types';
export default class ParticleEngine {
drawer: CanvasDrawer;
maxVolume = 800;
minSpawnPropability = 0.004;
spawnInterval: NodeJS.Timeout;
animating: boolean;
constructor(canvasDrawer: CanvasDrawer) {
this.drawer = canvasDrawer;
this.animating = false;
}
start() {
this.animating = true;
if (!this.spawnInterval) {
const that = this;
this.spawnInterval = setInterval(() => that.animate(), 60);
}
}
stop() {
this.animating = false;
}
animate() {
const that = this;
if (!that.animating) {
if (!this.hasParticles()) {
clearInterval(this.spawnInterval);
this.spawnInterval = null;
}
} else {
that._spawnParticles();
}
that.drawer.repaint();
}
hasParticles() {
for (const edge of this.drawer.cytoscape.edges().toArray()) {
if (
edge.data('particles') !== undefined &&
(edge.data('particles').normal.length > 0 || edge.data('particles').danger.length > 0)
) {
return true;
}
}
return false;
}
_spawnParticles() {
const cy = this.drawer.cytoscape;
const now = Date.now();
cy.edges().forEach((edge) => {
let particles: Particles = edge.data('particles');
const metrics: IntGraphMetrics = edge.data('metrics');
if (!metrics) {
return;
}
const rate = _.defaultTo(metrics.rate, 0);
const error_rate = _.defaultTo(metrics.error_rate, 0);
const volume = rate + error_rate;
let errorRate;
if (rate >= 0 && error_rate >= 0) {
errorRate = error_rate / rate;
} else {
errorRate = 0;
}
if (particles === undefined) {
particles = {
normal: [],
danger: [],
};
edge.data('particles', particles);
}
if (metrics && volume > 0) {
const spawnPropability = Math.min(volume / this.maxVolume, 1.0);
for (let i = 0; i < 5; i++) {
if (Math.random() <= spawnPropability + this.minSpawnPropability) {
const particle: Particle = {
velocity: 0.05 + Math.random() * 0.05,
startTime: now,
};
if (Math.random() < errorRate) {
particles.danger.push(particle);
} else {
particles.normal.push(particle);
}
}
}
}
});
}
count() {
const cy = this.drawer.cytoscape;
const count = _(cy.edges())
.map((edge) => edge.data('particles'))
.filter()
.map((particleArray) => particleArray.normal.length + particleArray.danger.length)
.sum();
return count;
}
}
================================================
FILE: src/panel/layout_options.ts
================================================
const options = {
name: 'cola',
animate: true, // whether to show the layout as it's running
refresh: 1, // number of ticks per frame; higher is faster but more jerky
maxSimulationTime: 3000, // max length in ms to run the layout
ungrabifyWhileSimulating: false, // so you can't drag nodes during layout
fit: true, // set by controller // on every layout reposition of nodes, fit the viewport
padding: 90, // padding around the simulation
boundingBox: undefined as undefined, // constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h }
nodeDimensionsIncludeLabels: false, // whether labels should be included in determining the space used by a node
// layout event callbacks
ready: function () {}, // on layoutready
stop: function () {}, // on layoutstop
// positioning options
randomize: false, // use random node positions at beginning of layout
avoidOverlap: true, // if true, prevents overlap of node bounding boxes
handleDisconnected: true, // if true, avoids disconnected components from overlapping
convergenceThreshold: 0.01, // when the alpha value (system energy) falls below this value, the layout stops
nodeSpacing: function (node: any) {
return 50;
}, // extra spacing around nodes
flow: undefined as undefined, // use DAG/tree flow layout if specified, e.g. { axis: 'y', minSeparation: 30 }
alignment: undefined as undefined, // relative alignment constraints on nodes, e.g. function( node ){ return { x: 0, y: 1 } }
gapInequalities: undefined as undefined, // list of inequality constraints for the gap between the nodes, e.g. [{"axis":"y", "left":node1, "right":node2, "gap":25}]
// different methods of specifying edge length
// each can be a constant numerical value or a function like `function( edge ){ return 2; }`
edgeLength: undefined as undefined, // sets edge length directly in simulation
edgeSymDiffLength: undefined as undefined, // symmetric diff edge length in simulation
edgeJaccardLength: undefined as undefined, // jaccard edge length in simulation
// iterations of cola algorithm; uses default values on undefined
unconstrIter: 50, // set by controller // unconstrained initial layout iterations
userConstIter: undefined as undefined, // initial layout iterations with user-specified constraints
allConstIter: undefined as undefined, // initial layout iterations with all constraints including non-overlap
// infinite layout options
infinite: false, // overrides all other options for a forces-all-the-time mode
};
export default options;
================================================
FILE: src/panel/serviceDependencyGraph/ServiceDependencyGraph.css
================================================
.service-dependency-graph-panel .graph-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
}
.service-dependency-graph-panel .canvas-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.service-dependency-graph-panel .zoom-button-container {
position: absolute;
top: 0;
right: 1rem;
z-index: 99;
}
================================================
FILE: src/panel/serviceDependencyGraph/ServiceDependencyGraph.tsx
================================================
import CanvasDrawer from 'panel/canvas/graph_canvas';
import cytoscape, { EdgeCollection, EdgeSingular, ElementDefinition, NodeSingular } from 'cytoscape';
import React, { PureComponent } from 'react';
import { PanelController } from '../PanelController';
import cyCanvas from 'cytoscape-canvas';
import cola from 'cytoscape-cola';
import layoutOptions from '../layout_options';
import { Statistics } from '../statistics/Statistics';
import _ from 'lodash';
import {
TableContent,
IntGraphMetrics,
IntGraph,
IntGraphNode,
IntGraphEdge,
PanelSettings,
IntSelectionStatistics,
} from 'types';
import { TemplateSrv, getTemplateSrv } from '@grafana/runtime';
import './ServiceDependencyGraph.css';
interface PanelState {
zoom: number | undefined;
animate: boolean | undefined;
controller: PanelController;
cy?: cytoscape.Core | undefined;
graphCanvas?: CanvasDrawer | undefined;
animateButtonClass?: string;
showStatistics: boolean;
data: IntGraph;
settings: PanelSettings;
layer: number | undefined;
maxLayer: number;
layerIncreaseFunction: any;
layerDecreaseFunction: any;
}
cyCanvas(cytoscape);
cytoscape.use(cola);
export class ServiceDependencyGraph extends PureComponent<PanelState, PanelState> {
ref: any;
selectionId: string;
currentType: string;
selectionStatistics: IntSelectionStatistics;
receiving: TableContent[];
sending: TableContent[];
resolvedDrillDownLink: string;
templateSrv: TemplateSrv;
initResize = true;
constructor(props: PanelState) {
super(props);
let animateButtonClass = 'fa fa-play-circle';
if (props.animate) {
animateButtonClass = 'fa fa-pause-circle';
}
this.state = {
...props,
showStatistics: false,
animateButtonClass: animateButtonClass,
animate: false,
};
this.ref = React.createRef();
this.templateSrv = getTemplateSrv();
}
componentDidMount() {
const cy: any = cytoscape({
container: this.ref,
zoom: this.state.zoom,
elements: this.props.data,
layout: {
name: 'cola',
},
style: [
{
selector: 'node',
css: {
'background-color': '#fbfbfb',
'background-opacity': 0,
},
},
{
selector: 'node:parent',
css: {
'background-opacity': 0.05,
shape: 'barrel',
},
},
{
selector: 'edge',
style: {
'curve-style': 'bezier',
'control-point-step-size': 100,
visibility: 'hidden',
},
},
],
wheelSensitivity: 0.125,
});
let graphCanvas = new CanvasDrawer(
this,
cy,
cy.cyCanvas({
zIndex: 1,
})
);
cy.on('render cyCanvas.resize', () => {
graphCanvas.repaint(true);
});
cy.on('select', 'node', () => this.onSelectionChange());
cy.on('unselect', 'node', () => this.onSelectionChange());
this.setState({
cy: cy,
graphCanvas: graphCanvas,
});
graphCanvas.start();
}
componentDidUpdate() {
this._updateGraph(this.props.data);
}
_updateGraph(graph: IntGraph) {
const cyNodes = this._transformNodes(graph.nodes);
const cyEdges = this._transformEdges(graph.edges);
const nodes = this.state.cy.nodes().toArray();
const updatedNodes = this._updateOrRemove(nodes, cyNodes);
// add new nodes
this.state.cy.add(cyNodes);
const edges = this.state.cy.edges().toArray();
this._updateOrRemove(edges, cyEdges);
// add new edges
this.state.cy.add(cyEdges);
if (this.initResize) {
this.initResize = false;
this.state.cy.resize();
this.state.cy.reset();
this.runLayout();
} else {
if (cyNodes.length > 0) {
_.each(updatedNodes, (node) => {
node.lock();
});
this.runLayout(true);
}
}
this.state.graphCanvas.repaint(true);
}
_transformNodes(nodes: IntGraphNode[]): ElementDefinition[] {
const cyNodes: ElementDefinition[] = _.map(nodes, (node) => {
const result: ElementDefinition = {
group: 'nodes',
data: {
id: node.data.id,
type: node.data.type,
external_type: node.data.external_type,
parent: node.data.parent,
layer: node.data.layer,
metrics: {
...node.data.metrics,
},
},
};
return result;
});
return cyNodes;
}
_transformEdges(edges: IntGraphEdge[]): ElementDefinition[] {
const cyEdges: ElementDefinition[] = _.map(edges, (edge) => {
const cyEdge: ElementDefinition = {
group: 'edges',
data: {
id: edge.data.source + ':' + edge.data.target,
source: edge.data.source,
target: edge.data.target,
metrics: {
...edge.data.metrics,
},
},
};
return cyEdge;
});
return cyEdges;
}
_updateOrRemove(dataArray: Array<NodeSingular | EdgeSingular>, inputArray: ElementDefinition[]) {
const elements: any[] = []; //(NodeSingular | EdgeSingular)[]
for (let i = 0; i < dataArray.length; i++) {
const element = dataArray[i];
const cyNode = _.find(inputArray, { data: { id: element.id() } });
if (cyNode) {
element.data(cyNode.data);
_.remove(inputArray, (n) => n.data.id === cyNode.data.id);
elements.push(element);
} else {
element.remove();
}
}
return elements;
}
onSelectionChange() {
const selection = this.state.cy.$(':selected');
if (selection.length === 1) {
this.updateStatisticTable();
this.setState({
showStatistics: true,
});
} else {
this.setState({
showStatistics: false,
});
}
}
getSettings(resolveVariables: boolean): PanelSettings {
return this.state.controller.getSettings(resolveVariables);
}
toggleAnimation() {
let newValue = !this.state.animate;
let animateButtonClass = 'fa fa-play-circle';
if (newValue) {
this.state.graphCanvas.startAnimation();
animateButtonClass = 'fa fa-pause-circle';
} else {
this.state.graphCanvas.stopAnimation();
}
this.setState({
animate: newValue,
animateButtonClass: animateButtonClass,
});
}
runLayout(unlockNodes = false) {
const that = this;
const options = {
...layoutOptions,
stop: function () {
if (unlockNodes) {
that.unlockNodes();
}
that.setState({
zoom: that.state.cy.zoom(),
});
},
};
this.state.cy.layout(options).run();
}
unlockNodes() {
this.state.cy.nodes().forEach((node: { unlock: () => void }) => {
node.unlock();
});
}
fit() {
const selection = this.state.graphCanvas.selectionNeighborhood;
if (selection && !selection.empty()) {
this.state.cy.fit(selection, 30);
} else {
this.state.cy.fit();
}
this.setState({
zoom: this.state.cy.zoom(),
});
}
zoom(zoom: number) {
const zoomStep = 0.25 * zoom;
const zoomLevel = Math.max(0.1, this.state.zoom + zoomStep);
this.setState({
zoom: zoomLevel,
});
this.state.cy.zoom(zoomLevel);
this.state.cy.center();
}
updateStatisticTable() {
const selection = this.state.cy.$(':selected');
if (selection.length === 1) {
const currentNode: NodeSingular = selection[0];
this.selectionId = currentNode.id().toString();
this.currentType = currentNode.data('type');
const receiving: TableContent[] = [];
const sending: TableContent[] = [];
const edges: EdgeCollection = selection.connectedEdges();
const metrics: IntGraphMetrics = selection.nodes()[0].data('metrics');
const requestCount = _.defaultTo(metrics.rate, -1);
const errorCount = _.defaultTo(metrics.error_rate, -1);
const duration = _.defaultTo(metrics.response_time, -1);
const threshold = _.defaultTo(metrics.threshold, -1);
this.selectionStatistics = {};
if (requestCount >= 0) {
this.selectionStatistics.requests = Math.floor(requestCount);
}
if (errorCount >= 0) {
this.selectionStatistics.errors = Math.floor(errorCount);
}
if (duration >= 0) {
this.selectionStatistics.responseTime = Math.floor(duration);
if (threshold >= 0) {
this.selectionStatistics.threshold = Math.floor(threshold);
this.selectionStatistics.thresholdViolation = duration > threshold;
}
}
for (let i = 0; i < edges.length; i++) {
const actualEdge: EdgeSingular = edges[i];
const sendingCheck: boolean = actualEdge.source().id() === this.selectionId;
let node: NodeSingular;
if (sendingCheck) {
node = actualEdge.target();
} else {
node = actualEdge.source();
}
const sendingObject: TableContent = {
name: node.id(),
responseTime: '-',
rate: '-',
error: '-',
};
const edgeMetrics: IntGraphMetrics = actualEdge.data('metrics');
if (edgeMetrics !== undefined) {
const { response_time, rate, error_rate } = edgeMetrics;
if (rate !== undefined) {
sendingObject.rate = Math.floor(rate).toString();
}
if (response_time !== undefined) {
sendingObject.responseTime = Math.floor(response_time) + ' ms';
}
if (error_rate !== undefined && rate !== undefined) {
sendingObject.error = Math.floor(error_rate / (rate / 100)) + '%';
}
}
if (sendingCheck) {
sending.push(sendingObject);
} else {
receiving.push(sendingObject);
}
}
this.receiving = receiving;
this.sending = sending;
this.generateDrillDownLink();
}
}
generateDrillDownLink() {
const { drillDownLink } = this.getSettings(false);
if (drillDownLink !== undefined) {
const link = drillDownLink.replace('{}', this.selectionId);
this.resolvedDrillDownLink = this.templateSrv.replace(link);
}
}
render() {
if (this.state.cy !== undefined) {
this._updateGraph(this.props.data);
}
return (
<div className="graph-container">
<div className="service-dependency-graph">
<div className="canvas-container" ref={(ref) => (this.ref = ref)}></div>
<div className="zoom-button-container">
<button className="btn navbar-button width-100" onClick={() => this.toggleAnimation()}>
<i className={this.state.animateButtonClass}></i>
</button>
<button className="btn navbar-button width-100" onClick={() => this.runLayout()}>
<i className="fa fa-sitemap"></i>
</button>
<button className="btn navbar-button width-100" onClick={() => this.fit()}>
<i className="fa fa-dot-circle-o"></i>
</button>
<button className="btn navbar-button width-100" onClick={() => this.props.layerIncreaseFunction()}>
<i className="fa fa-plus"></i>
</button>
<button className="btn navbar-button width-100" onClick={() => this.props.layerDecreaseFunction()}>
<i className="fa fa-minus"></i>
</button>
<span>
Layer {this.state.controller.state.currentLayer}/{this.state.maxLayer}
</span>
</div>
</div>
<Statistics
show={this.state.showStatistics}
selectionId={this.selectionId}
resolvedDrillDownLink={this.resolvedDrillDownLink}
selectionStatistics={this.selectionStatistics}
currentType={this.currentType}
showBaselines={this.getSettings(true).showBaselines}
receiving={this.receiving}
sending={this.sending}
/>
</div>
);
}
}
================================================
FILE: src/panel/statistics/IncomingStatistics.tsx
================================================
import React from 'react';
import { TableContent } from 'types';
export const NodeStatistics = (receiving: TableContent[]) => {
return (
<>
<div className="secondHeader--selection">Incoming Statistics</div>
{() => {
if (receiving.length > 0) {
return (
<table className="table--selection">
<tr className="table--selection--head">
<th>Name</th>
<th className="table--th--selectionSmall">Time</th>
<th className="table--th--selectionSmall">Requests</th>
<th className="table--th--selectionSmall">Error Rate</th>
</tr>
{receiving.map((node: TableContent, index: number) => (
<tr key={'row-' + index}>
<td className="table--td--selection" title="{{node.name}}">
{node.name}
</td>
<td className="table--td--selection">{node.responseTime}</td>
<td className="table--td--selection">{node.rate}</td>
<td className="table--td--selection">{node.error}</td>
</tr>
))}
</table>
);
}
return <div className="no-data--selection">No incoming statistics available.</div>;
}}
</>
);
};
================================================
FILE: src/panel/statistics/NodeStatistics.tsx
================================================
import React from 'react';
import { IntTableHeader } from '../../types';
import { TableContent } from 'types';
import SortableTable from './SortableTable';
import roundPercentageToDecimal from './utils/Utils';
interface NodeStatisticsProps {
nodeList: TableContent[];
noDataText: string;
title: string;
}
const tableHeaders: IntTableHeader[] = [
{ text: 'Name', dataField: 'name', sort: true, isKey: true },
{ text: 'Time', dataField: 'time', sort: true, ignoreLiteral: ' ms' },
{ text: 'Requests', dataField: 'requests', sort: true, ignoreLiteral: '' },
{ text: 'Error Rate', dataField: 'error_rate', sort: true, ignoreLiteral: '%' },
];
function getStatisticsTable(noDataText: string, nodeList: TableContent[]) {
if (nodeList.length > 0) {
return (
<SortableTable
tableHeaders={tableHeaders}
data={nodeList.map((node: TableContent) => {
return {
name: node.name,
time: node.responseTime,
requests: node.rate,
error_rate: roundPercentageToDecimal(2, node.error),
};
})}
/>
);
} else {
return <div className="no-data--selection">{noDataText}</div>;
}
}
export const NodeStatistics: React.FC<NodeStatisticsProps> = ({ nodeList, noDataText, title }) => {
return (
<div>
<div className="secondHeader--selection">{title}</div>
{getStatisticsTable(noDataText, nodeList)}
</div>
);
};
================================================
FILE: src/panel/statistics/SortableTable.tsx
================================================
import React from 'react';
import { IntTableHeader, NodeData } from '../../types';
import BootstrapTable from 'react-bootstrap-table-next';
interface SortableTableProps {
tableHeaders: IntTableHeader[];
data: NodeData[];
}
function sort(a: string, b: string, order: string, ignoreLiteral: string) {
let cleanA = a.replace(ignoreLiteral, '');
let cleanB = b.replace(ignoreLiteral, '');
if ((order === 'asc' && cleanA === '-') || (order !== 'asc' && cleanB === '-')) {
return -1;
}
if ((order === 'asc' && cleanB === '-') || (order !== 'asc' && cleanA === '-')) {
return 1;
}
if (order === 'asc') {
return Number(cleanA) - Number(cleanB);
}
return Number(cleanB) - Number(cleanA);
}
export const SortableTable: React.FC<SortableTableProps> = ({ tableHeaders, data }) => {
tableHeaders.forEach(function (value, i) {
value.classes = 'table--td--selection';
if (i !== 0) {
value.sortFunc = (a: string, b: string, order: string, _dataField: any, _rowA: any) => {
return sort(a, b, order, value.ignoreLiteral);
};
}
});
return (
<BootstrapTable
keyField="name"
data={data}
columns={tableHeaders}
classes="table--selection"
headerClasses="table--selection table--selection--head"
/>
);
};
export default SortableTable;
================================================
FILE: src/panel/statistics/Statistics.css
================================================
.service-dependency-graph-panel .margin {
margin: 10px;
}
.service-dependency-graph-panel .statistics {
flex-basis: 0;
transition: flex-basis 500ms ease-in-out;
overflow-y: scroll;
}
.service-dependency-graph-panel .statistics.show {
flex-basis: 30rem;
padding-left: 0.5%;
}
================================================
FILE: src/panel/statistics/Statistics.tsx
================================================
import React from 'react';
import { NodeStatistics } from './NodeStatistics';
import '../../css/novatec-service-dependency-graph-panel.css';
import './Statistics.css';
import { IntSelectionStatistics, TableContent } from 'types';
import roundPercentageToDecimal from './utils/Utils';
interface StatisticsProps {
show: boolean;
selectionId: string | number;
resolvedDrillDownLink: string;
selectionStatistics: IntSelectionStatistics;
currentType: string;
showBaselines: boolean;
receiving: TableContent[];
sending: TableContent[];
}
export const Statistics: React.FC<StatisticsProps> = ({
show,
selectionId,
resolvedDrillDownLink,
selectionStatistics,
currentType,
showBaselines,
receiving,
sending,
}) => {
let statisticsClass = 'statistics';
let statistics = <div></div>;
if (show) {
statisticsClass = 'statistics show ';
let drilldownLink = <div></div>;
if (resolvedDrillDownLink && resolvedDrillDownLink.length > 0 && currentType === 'INTERNAL') {
drilldownLink = (
<a target="_blank" rel="noreferrer" href={resolvedDrillDownLink}>
<i className="fa fa-paper-plane-o margin"></i>
</a>
);
}
const requests =
selectionStatistics.requests >= 0 ? (
<tr>
<td className="table--td--selection">Requests</td>
<td className="table--td--selection">{selectionStatistics.requests}</td>
</tr>
) : null;
const errors =
selectionStatistics.errors >= 0 ? (
<tr>
<td className="table--td--selection">Errors</td>
<td className="table--td--selection">{selectionStatistics.errors}</td>
</tr>
) : null;
let errorRate =
selectionStatistics.requests > 0 && selectionStatistics.errors >= 0 ? (
<tr>
<td className="table--td--selection">Error Rate</td>
<td className="table--td--selection">
{roundPercentageToDecimal(
2,
((100 / selectionStatistics.requests) * selectionStatistics.errors).toString()
)}
</td>
</tr>
) : null;
let avgResponseTime =
selectionStatistics.responseTime >= 0 ? (
<tr>
<td className="table--td--selection">Avg. Response Time</td>
<td className="table--td--selection">{selectionStatistics.responseTime} ms</td>
</tr>
) : null;
let threshold = selectionStatistics.thresholdViolation ? (
<td className="table--td--selection threshold--bad">
Bad ({'>'} {selectionStatistics.threshold}ms)
</td>
) : (
<td className="table--td--selection threshold--good"> Good (<= {selectionStatistics.threshold}ms) </td>
);
let baseline =
showBaselines && selectionStatistics.threshold ? (
<tr>
<td className="table--td--selection">Response Time Health (Upper Baseline)</td>
{threshold}
</tr>
) : null;
statistics = (
<div className="statistics">
<div className="header--selection">
{selectionId}
{drilldownLink}
</div>
<div className="secondHeader--selection">Statistics</div>
<table className="table--selection">
<tr className="table--selection--head">
<th>Name</th>
<th className="table--th--selectionMedium">Value</th>
</tr>
{requests}
{errors}
{errorRate}
{avgResponseTime}
{baseline}
</table>
<NodeStatistics
nodeList={receiving}
noDataText="No incoming statistics available."
title="Incoming Statistics"
/>
<NodeStatistics nodeList={sending} noDataText="No outgoing statistics available." title="Outgoing Statistics" />
</div>
);
}
return <div className={statisticsClass}>{statistics}</div>;
};
================================================
FILE: src/panel/statistics/utils/Utils.ts
================================================
function roundPercentageToDecimal(decimal: number, value: string) {
if (value !== '-') {
let valueDecimals = _getDecimalsOf(parseFloat(value));
if (valueDecimals > decimal) {
value = parseFloat(value).toFixed(decimal) + '%';
}
}
return value;
}
function _getDecimalsOf(value: number) {
if (Math.floor(value) !== value) {
return value.toString().split('.')[1].length || 0;
}
return 0;
}
export default roundPercentageToDecimal;
================================================
FILE: src/plugin.json
================================================
{
"type": "panel",
"name": "Service Dependency Graph",
"id": "novatec-sdg-panel",
"info": {
"version": "4.2.0",
"updated": "2025-03-26",
"description": "Service Dependency Graph panel for Grafana. Shows metric-based, dynamic dependency graph between services, indicates responsetime, load and error rate statistic for individual services and communication edges. Shows communication to external services, such as Web calls, database calls, message queues, LDAP calls, etc. Provides a details dialog for each selected service that shows statistics about incoming and outgoing traffic.",
"author": {
"name": "Novatec Consulting GmbH",
"url": "https://www.novatec-gmbh.de/"
},
"keywords": [
"grafana",
"plugin",
"service-dependency-graph",
"topology"
],
"logos": {
"small": "img/novatec_sdg_panel_logo.svg",
"large": "img/novatec_sdg_panel_logo.svg"
},
"links": [
{
"name": "Project site",
"url": "https://github.com/NovatecConsulting/novatec-service-dependency-graph-panel.git"
},
{
"name": "Apache License",
"url": "https://github.com/NovatecConsulting/novatec-service-dependency-graph-panel/blob/master/LICENSE"
}
],
"screenshots": [
{
"name": "Showcase",
"path": "img/data-example-1.png"
}
]
},
"dependencies": {
"grafanaDependency": ">=10.4.0",
"plugins": []
}
}
================================================
FILE: src/processing/graph_generator.ts
================================================
import _ from 'lodash';
import { isPresent } from './utils/Utils';
import { PanelController } from '../panel/PanelController';
import {
GraphDataElement,
IntGraph,
IntGraphEdge,
IntGraphMetrics,
IntGraphNode,
EnGraphNodeType,
GraphDataType,
} from '../types';
import NodeTree from './node_tree';
import NodeSubstitutor from './node_substitutor';
class GraphGenerator {
controller: PanelController;
nodeSubstitutor: NodeSubstitutor;
constructor(controller: PanelController) {
this.controller = controller;
this.nodeSubstitutor = new NodeSubstitutor();
}
_createNode(dataElements: GraphDataElement[], nodeTree: NodeTree): IntGraphNode | undefined {
if (!dataElements || dataElements.length <= 0) {
return undefined;
}
const sumMetrics = this.controller.getSettings(true).sumTimings;
let nodeName = dataElements[0].target;
if (nodeName === '' || nodeName === undefined || nodeName === null) {
nodeName = 'undefined';
}
const internalNode =
_.some(dataElements, ['type', GraphDataType.INTERNAL]) ||
_.some(dataElements, ['type', GraphDataType.EXTERNAL_IN]);
const nodeType = internalNode ? EnGraphNodeType.INTERNAL : EnGraphNodeType.EXTERNAL;
const metrics: IntGraphMetrics = {};
const node: IntGraphNode = {
data: {
id: nodeName,
label: nodeName,
external_type: nodeType,
type: nodeType,
layer: 0,
metrics,
namespace: [],
},
};
//get first element where namespace is defined.
const namespaceElement = dataElements.find((el) => el.namespace !== undefined);
if (namespaceElement) {
const namespace = namespaceElement.namespace;
node.data.namespace = namespace;
node.data.layer = namespace.length;
node.data.parent = namespace[namespace.length - 1];
this._updateMaxLayer(node.data.layer);
}
const aggregationFunction = sumMetrics ? _.sum : _.mean;
if (internalNode) {
metrics.rate = _.sum(_.map(dataElements, (element) => element.data.rate_in));
metrics.error_rate = _.sum(_.map(dataElements, (element) => element.data.error_rate_in));
const response_timings = _.map(dataElements, (element) => element.data.response_time_in).filter(isPresent);
if (response_timings.length > 0) {
metrics.response_time = aggregationFunction(response_timings);
}
} else {
metrics.rate = _.sum(_.map(dataElements, (element) => element.data.rate_out));
metrics.error_rate = _.sum(_.map(dataElements, (element) => element.data.error_rate_out));
const response_timings = _.map(dataElements, (element) => element.data.response_time_out).filter(isPresent);
if (response_timings.length > 0) {
metrics.response_time = aggregationFunction(response_timings);
}
const externalType = _(dataElements)
.map((element) => element.data.type)
.uniq()
.value();
if (externalType.length === 1) {
node.data.external_type = externalType[0];
}
}
// metrics which are same for internal and external nodes
metrics.threshold = _(dataElements)
.map((element) => element.data.threshold)
.filter()
.mean();
if (sumMetrics) {
const requestCount = _.defaultTo(metrics.rate, 0) + _.defaultTo(metrics.error_rate, 0);
const response_time = _.defaultTo(metrics.response_time, -1);
if (requestCount > 0 && response_time >= 0) {
metrics.response_time = response_time / requestCount;
}
}
const { rate, error_rate } = metrics;
if (rate + error_rate > 0) {
metrics.success_rate = (1.0 / (rate + error_rate)) * rate;
} else {
metrics.success_rate = 1.0;
}
nodeTree.addNode(node);
this.nodeSubstitutor.add(node);
return node;
}
_createMissingNodes(data: GraphDataElement[], nodes: IntGraphNode[]): IntGraphNode[] {
const existingNodeNames = _.map(nodes, (node) => node.data.id);
const expectedNodeNames = _.uniq(_.flatMap(data, (dataElement) => [dataElement.source, dataElement.target])).filter(
isPresent
);
const missingNodeNames = _.difference(expectedNodeNames, existingNodeNames);
const missingNodes = _.map(missingNodeNames, (name) => {
let nodeType: EnGraphNodeType;
let external_type: string | undefined;
// derive node type
let elementSrc = _.find(data, { source: name });
let elementTrgt = _.find(data, { target: name });
if (elementSrc && elementSrc.type === GraphDataType.EXTERNAL_IN) {
nodeType = EnGraphNodeType.EXTERNAL;
external_type = elementSrc.data.type;
} else if (elementTrgt && elementTrgt.type === GraphDataType.EXTERNAL_OUT) {
nodeType = EnGraphNodeType.EXTERNAL;
external_type = elementTrgt.data.type;
} else {
nodeType = EnGraphNodeType.INTERNAL;
}
let value: IntGraphNode = {
data: {
id: name,
type: nodeType,
external_type: external_type,
metrics: {},
layer: 0,
},
};
this.nodeSubstitutor.add(value);
return value;
});
return missingNodes;
}
_createNodes(data: GraphDataElement[]): IntGraphNode[] {
let tree = new NodeTree();
const filteredData = _.filter(
data,
(dataElement) =>
dataElement.source !== dataElement.target ||
(_.has(dataElement, 'target') && !_.has(dataElement, 'target')) ||
(!_.has(dataElement, 'target') && _.has(dataElement, 'target'))
);
const targetGroups = _.groupBy(filteredData, 'target');
const explicitlyNamedNodes = _.map(targetGroups, (group) => this._createNode(group, tree)).filter(isPresent);
// ensure that all nodes exist, even we have no data for them
const missingNodes = this._createMissingNodes(filteredData, explicitlyNamedNodes);
missingNodes.forEach((node) => tree.addNode(node));
const allNodes = tree.getNodesFromLayer(this.controller.state.currentLayer);
return allNodes;
}
_resolveSubstitute(name: string): string {
return this.nodeSubstitutor.substituteUntilLayer(
name,
this.controller.state.currentLayer,
this.controller.maxLayer
);
}
_createEdge(dataElement: GraphDataElement): IntGraphEdge | undefined {
let { source, target } = dataElement;
if (source === undefined || target === undefined) {
console.error('source and target are necessary to create an edge', dataElement);
return undefined;
}
const metrics: IntGraphMetrics = {};
source = this._resolveSubstitute(source);
target = this._resolveSubstitute(target);
if (source === target) {
return undefined;
}
const edge: IntGraphEdge = {
source: source,
target: target,
data: {
source,
target,
metrics,
},
};
const { rate_out, rate_in, error_rate_out, response_time_out } = dataElement.data;
if (!_.isUndefined(rate_out)) {
metrics.rate = rate_out;
} else if (!_.isUndefined(rate_in)) {
metrics.rate = rate_in;
}
if (!_.isUndefined(error_rate_out)) {
metrics.error_rate = error_rate_out;
}
if (!_.isUndefined(response_time_out)) {
const { sumTimings } = this.controller.getSettings(true);
if (sumTimings && metrics.rate) {
metrics.response_time = response_time_out / metrics.rate;
} else {
metrics.response_time = response_time_out;
}
}
return edge;
}
_resolveEdgeMap(edges: IntGraphEdge[]) {
let edgeMap: Map<string, IntGraphEdge[]> = new Map();
edges.forEach((edge) => {
if (edgeMap.get(edge.source + '-' + edge.target)) {
edgeMap.get(edge.source + '-' + edge.target).push(edge);
} else {
edgeMap.set(edge.source + '-' + edge.target, [edge]);
}
});
return edgeMap;
}
_mergeArrayOfEdges(edges: IntGraphEdge[]) {
let errorRateCounter = 0;
let rateCounter = 0;
let responseTimeCounter = 0;
let successRateCounter = 0;
let thresholdCounter = 0;
const mergedEdge: IntGraphEdge = {
target: '',
source: '',
data: {
source: '',
target: '',
metrics: {},
},
};
edges.forEach((edge) => {
if (mergedEdge.source === '') {
mergedEdge.source = edge.source;
mergedEdge.data.source = edge.data.source;
}
if (mergedEdge.target === '') {
mergedEdge.target = edge.target;
mergedEdge.data.target = edge.data.target;
}
if (edge.data.metrics.error_rate) {
mergedEdge.data.metrics.error_rate = mergedEdge.data.metrics.error_rate
? mergedEdge.data.metrics.error_rate + edge.data.metrics.error_rate
: (mergedEdge.data.metrics.error_rate = edge.data.metrics.error_rate);
errorRateCounter++;
}
if (edge.data.metrics.rate) {
mergedEdge.data.metrics.rate = mergedEdge.data.metrics.rate
? mergedEdge.data.metrics.rate + edge.data.metrics.rate
: (mergedEdge.data.metrics.rate = edge.data.metrics.rate);
rateCounter++;
}
if (edge.data.metrics.response_time) {
mergedEdge.data.metrics.response_time = mergedEdge.data.metrics.response_time
? mergedEdge.data.metrics.response_time + edge.data.metrics.response_time
: (mergedEdge.data.metrics.response_time = edge.data.metrics.response_time);
responseTimeCounter++;
}
if (edge.data.metrics.success_rate) {
mergedEdge.data.metrics.success_rate = mergedEdge.data.metrics.success_rate
? mergedEdge.data.metrics.success_rate + edge.data.metrics.success_rate
: (mergedEdge.data.metrics.success_rate = edge.data.metrics.success_rate);
successRateCounter++;
}
if (edge.data.metrics.threshold) {
mergedEdge.data.metrics.threshold = mergedEdge.data.metrics.threshold
? mergedEdge.data.metrics.threshold + edge.data.metrics.threshold
: (mergedEdge.data.metrics.threshold = edge.data.metrics.threshold);
thresholdCounter++;
}
});
if (mergedEdge.data.metrics.error_rate) {
mergedEdge.data.metrics.error_rate = mergedEdge.data.metrics.error_rate / errorRateCounter;
}
if (mergedEdge.data.metrics.rate) {
mergedEdge.data.metrics.rate = mergedEdge.data.metrics.rate / rateCounter;
}
if (mergedEdge.data.metrics.response_time) {
mergedEdge.data.metrics.response_time = mergedEdge.data.metrics.response_time / responseTimeCounter;
}
if (mergedEdge.data.metrics.success_rate) {
mergedEdge.data.metrics.success_rate = mergedEdge.data.metrics.success_rate / successRateCounter;
}
if (mergedEdge.data.metrics.threshold) {
mergedEdge.data.metrics.threshold = mergedEdge.data.metrics.threshold / thresholdCounter;
}
return mergedEdge;
}
_edgeMapToMergedEdges(edgeMap: Map<string, IntGraphEdge[]>) {
let edges: IntGraphEdge[] = [];
for (const entry of edgeMap.values()) {
edges.push(this._mergeArrayOfEdges(entry));
}
return edges;
}
_mergeEdges(edges: IntGraphEdge[]) {
const edgeMap = this._resolveEdgeMap(edges);
this._edgeMapToMergedEdges(edgeMap);
return edges;
}
_createEdges(data: GraphDataElement[]): IntGraphEdge[] {
const filteredData = _(data)
.filter((e) => !!e.source)
.filter((e) => e.source !== e.target)
.filter((e) => e.target !== null || e.source !== null)
.value();
const edges = _.map(filteredData, (element) => this._createEdge(element));
const filteredEdges = edges.filter(isPresent);
return this._mergeEdges(filteredEdges);
}
_filterData(graph: IntGraph): IntGraph {
const { filterEmptyConnections: filterData } = this.controller.getSettings(true);
if (filterData) {
const filteredGraph: IntGraph = {
nodes: [],
edges: [],
};
// filter empty connections
filteredGraph.edges = _.filter(graph.edges, (edge) => _.size(edge.data.metrics) > 0);
filteredGraph.nodes = _.filter(graph.nodes, (node) => {
const id = node.data.id;
// don't filter connected elements and parents
if (
_.some(graph.edges, { source: id }) ||
_.some(graph.edges, { target: id }) ||
node.data.type === EnGraphNodeType.PARENT
) {
return true;
}
const metrics = node.data.metrics;
if (!metrics) {
return false; // no metrics
}
// only if rate, error rate or response time is available
return (
_.defaultTo(metrics.rate, -1) >= 0 ||
_.defaultTo(metrics.error_rate, -1) >= 0 ||
_.defaultTo(metrics.response_time, -1) >= 0
);
});
return filteredGraph;
} else {
return graph;
}
}
generateGraph(graphData: GraphDataElement[]): IntGraph {
const nodes = this._createNodes(graphData);
const edges = this._createEdges(graphData);
const graph: IntGraph = {
nodes,
edges,
};
const filteredGraph = this._filterData(graph);
return filteredGraph;
}
_updateMaxLayer(layer: number) {
if (layer > this.controller.maxLayer) {
this.controller.maxLayer = layer;
}
}
}
export default GraphGenerator;
================================================
FILE: src/processing/node_substitutor.ts
================================================
import _ from 'lodash';
import { IntGraphNode } from '../types';
class NodeSubstitutor {
private _substitutionMap: any;
constructor() {
this._substitutionMap = new Map();
}
add(node: IntGraphNode) {
const nameSpace = node.data.namespace;
if (nameSpace && nameSpace.length > 0) {
let currentValue = nameSpace;
let currentKey = node.data.label;
this._substitutionMap.set(currentKey, currentValue);
}
}
substituteUntilLayer(nodeName: string, layer: number, maxLayer: number) {
if (!this._substitutionMap.has(nodeName)) {
return nodeName;
}
const nameSpace = this._substitutionMap.get(nodeName);
const nsLeng = nameSpace.length;
if (nsLeng - 1 < layer) {
return nodeName;
}
return nameSpace[layer];
}
}
export default NodeSubstitutor;
================================================
FILE: src/processing/node_tree.ts
================================================
import _ from 'lodash';
import { EnGraphNodeType, IntGraphMetrics, IntGraphNode, NodeTreeElement } from '../types';
class NodeTree {
private _root: NodeTreeElement;
private _metricMap: any;
constructor() {
this._root = { id: 'root', children: [] };
this._metricMap = {};
}
addNode(node: IntGraphNode) {
if (node.data.id !== 'undefined') {
this._addNode({ id: node.data.id, node: node, children: [] }, this._root, 0);
}
}
getNodesFromLayer(layer: number) {
let nodes = this._getNodesFromLayer(this._root, layer, 0);
nodes.forEach((element) => {
if (this._metricMap[element.data.id]) {
(Object.keys(element.data.metrics) as Array<keyof typeof element.data.metrics>).forEach(
(key) => (element.data.metrics[key] = element.data.metrics[key] / this._metricMap[element.data.id][key])
);
}
});
return this._getNodesFromLayer(this._root, layer, 0);
}
getNamePath(namePath: string[]) {
let currentLayer = this._root;
namePath.forEach((element) => {
currentLayer = this._getObjectFromArray(currentLayer.children, element);
});
return currentLayer;
}
private _getNodesFromLayer(currentNode: NodeTreeElement, layer: number, layerCounter: number): IntGraphNode[] {
let children;
if (layer === layerCounter) {
children = currentNode.children.map((element) => element.node);
if (currentNode !== this._root) {
children.push(currentNode.node);
}
return children;
}
layerCounter++;
children = _.flatten(currentNode.children.map((element) => this._getNodesFromLayer(element, layer, layerCounter)));
if (currentNode !== this._root) {
children.push(currentNode.node);
}
return children;
}
private _getNameSpaceFromCurrentLevel(namespace: string[], currentLevel: number) {
let nameSpaces = [];
for (let i = 0; i < currentLevel; i++) {
nameSpaces.push(namespace[i]);
}
return nameSpaces;
}
private _sumMetrics(sourceNode: IntGraphNode, targetNode: IntGraphNode): IntGraphMetrics {
const source = sourceNode.data.metrics;
const target = targetNode.data.metrics;
let metrics: IntGraphMetrics = {};
if (!this._metricMap[targetNode.data.id]) {
this._metricMap[targetNode.data.id] = {};
}
if (target.rate || source.rate) {
metrics.rate = (target.rate ? target.rate : 0) + (source.rate ? source.rate : 0);
if (target.rate && source.rate && !isNaN(target.rate) && !isNaN(source.rate)) {
this._metricMap[targetNode.data.id].rate
? (this._metricMap[targetNode.data.id].rate = this._metricMap[targetNode.data.id].rate + 1)
: (this._metricMap[targetNode.data.id].rate = 1);
} else {
if (!this._metricMap[targetNode.data.id].rate) {
this._metricMap[targetNode.data.id].rate = 1;
}
}
}
if (target.response_time || source.response_time) {
metrics.response_time =
(target.response_time ? target.response_time : 0) + (source.response_time ? source.response_time : 0);
if (
target.response_time &&
source.response_time &&
!isNaN(target.response_time) &&
!isNaN(source.response_time)
) {
this._metricMap[targetNode.data.id].response_time
? (this._metricMap[targetNode.data.id].response_time = this._metricMap[targetNode.data.id].response_time + 1)
: (this._metricMap[targetNode.data.id].response_time = 1);
} else {
if (!this._metricMap[targetNode.data.id].response_time) {
this._metricMap[targetNode.data.id].response_time = 1;
}
}
}
if (target.success_rate || source.success_rate) {
metrics.success_rate =
(target.success_rate ? target.success_rate : 0) + (source.success_rate ? source.success_rate : 0);
if (target.success_rate && source.success_rate && !isNaN(target.success_rate) && !isNaN(source.success_rate)) {
this._metricMap[targetNode.data.id].success_rate
? (this._metricMap[targetNode.data.id].success_rate = this._metricMap[targetNode.data.id].success_rate + 1)
: (this._metricMap[targetNode.data.id].success_rate = 1);
} else {
if (!this._metricMap[targetNode.data.id].success_rate) {
this._metricMap[targetNode.data.id].success_rate = 1;
}
}
}
return metrics;
}
private _getObjectFromArray(array: NodeTreeElement[], id: string) {
for (let i = 0; i < array.length; i++) {
if (array[i].node.data.label === id) {
return array[i];
}
}
return undefined;
}
private _addNode(nodeToAdd: NodeTreeElement, currentLayerNode: NodeTreeElement, currentLevel: number) {
const namespace = nodeToAdd.node.data.namespace;
const namespaceLength = namespace ? namespace.length : 0;
if (namespaceLength === currentLevel) {
const possibleDuplicate = _.find(currentLayerNode.children, function (o) {
return o.id === nodeToAdd.id;
});
if (possibleDuplicate) {
//Object.assign(possibleDuplicate.node.data.metrics, nodeToAdd.node.data.metrics);
//TODO: Copy/merge metrics
} else {
currentLayerNode.children.push(nodeToAdd);
}
} else {
const nextLayerNode = this._getObjectFromArray(
currentLayerNode.children,
nodeToAdd.node.data.namespace[currentLevel]
);
if (nextLayerNode === undefined) {
const children: NodeTreeElement[] = [];
const newNode = {
id: nodeToAdd.node.data.namespace[currentLevel],
children: children,
node: {
data: {
id: nodeToAdd.node.data.namespace[currentLevel],
type: EnGraphNodeType.PARENT,
label: nodeToAdd.node.data.namespace[currentLevel],
parent: nodeToAdd.node.data.namespace[currentLevel - 1],
namespace: this._getNameSpaceFromCurrentLevel(nodeToAdd.node.data.namespace, currentLevel),
layer: currentLevel,
metrics: {},
},
},
};
currentLayerNode.children.push(newNode);
currentLevel++;
newNode.node.data.metrics = this._sumMetrics(nodeToAdd.node, newNode.node);
this._addNode(nodeToAdd, newNode, currentLevel);
} else {
const nextTopLayerNode = this._getObjectFromArray(
currentLayerNode.children,
nodeToAdd.node.data.namespace[currentLevel]
);
nextTopLayerNode.node.data.type = EnGraphNodeType.PARENT;
nextTopLayerNode.node.data.metrics = this._sumMetrics(nodeToAdd.node, nextTopLayerNode.node);
currentLevel++;
this._addNode(nodeToAdd, nextTopLayerNode, currentLevel);
}
}
}
}
export default NodeTree;
================================================
FILE: src/processing/pre_processor.ts
================================================
import { DataFrame } from '@grafana/data';
import _ from 'lodash';
import { PanelController } from '../panel/PanelController';
import { GraphDataElement, GraphDataType, CurrentData } from '../types';
class PreProcessor {
controller: PanelController;
constructor(controller: PanelController) {
this.controller = controller;
}
_transformObjects(data: any[]): GraphDataElement[] {
const {
aggregationType,
sourceColumn,
targetColumn,
extOrigin: externalSource,
extTarget: externalTarget,
namespaceDelimiter,
} = this.controller.getSettings(true).dataMapping;
const result = _.map(data, (dataObject) => {
let source = _.has(dataObject, sourceColumn) && dataObject[sourceColumn] !== '';
let target = _.has(dataObject, targetColumn) && dataObject[targetColumn] !== '';
const extSource = _.has(dataObject, externalSource) && dataObject[externalSource] !== '';
const extTarget = _.has(dataObject, externalTarget) && dataObject[externalTarget] !== '';
let trueCount = [source, target, extSource, extTarget].filter((e) => e).length;
if (trueCount > 1) {
if (target && extTarget) {
target = false;
} else if (source && extSource) {
source = false;
} else {
console.error('source-target conflict for data element', dataObject);
return undefined;
}
}
const result: GraphDataElement = {
target: '',
data: dataObject,
type: GraphDataType.INTERNAL,
};
if (_.has(dataObject, 'namespace')) {
const nameSpace = _.get(dataObject, 'namespace');
if (nameSpace) {
const namespaceResolved = nameSpace.split(namespaceDelimiter);
result.namespace = namespaceResolved;
}
}
if (trueCount === 0) {
result.target = dataObject[aggregationType];
result.type = GraphDataType.EXTERNAL_IN;
} else {
if (source || target) {
if (source) {
result.source = dataObject[sourceColumn];
result.target = dataObject[aggregationType];
} else {
result.source = dataObject[aggregationType];
result.target = dataObject[targetColumn];
}
if (result.source === result.target) {
result.type = GraphDataType.SELF;
}
} else if (extSource) {
result.source = dataObject[externalSource];
result.target = dataObject[aggregationType];
result.type = GraphDataType.EXTERNAL_IN;
} else if (extTarget) {
result.source = dataObject[aggregationType];
result.target = dataObject[externalTarget];
result.type = GraphDataType.EXTERNAL_OUT;
}
}
return result;
});
const filteredResult: GraphDataElement[] = result.filter(
(element): element is GraphDataElement => element !== null
);
return filteredResult;
}
_mergeGraphData(data: GraphDataElement[]): GraphDataElement[] {
const groupedData = _.values(_.groupBy(data, (element) => element.source + '<--->' + element.target));
const mergedData = _.map(groupedData, (group) => {
return _.reduce(group, (result, next) => {
return _.merge(result, next);
});
});
return mergedData;
}
_cleanMetaData(columnMapping: any, metaData: any) {
const result: any = {};
_.forOwn(columnMapping, (value, key) => {
if (_.has(metaData, value)) {
result[key] = metaData[value];
}
});
return result;
}
_extractColumnNames(data: GraphDataElement[]): string[] {
const columnNames: string[] = _(data)
.flatMap((dataElement) => _.keys(dataElement.data))
.uniq()
.sort()
.value();
return columnNames;
}
_getField(fieldName: string, fields: any[]) {
for (const field of fields) {
if (field.name === fieldName) {
return field;
}
}
return undefined;
}
_mergeSeries(series: any[]) {
let mergedSeries: any = undefined;
for (const seriesElement of series) {
if (mergedSeries === undefined) {
mergedSeries = seriesElement;
} else {
for (const field of seriesElement.fields) {
const mergedField = this._getField(field.name, mergedSeries.fields);
if (mergedField === undefined) {
mergedSeries.fields.push(field);
} else {
mergedField.values = _.concat(field.values, mergedField.values);
}
}
}
}
return mergedSeries;
}
_dataToRows(inputDataSets: any) {
let rows: any[] = [];
const {
aggregationType,
sourceColumn,
targetColumn,
namespaceColumn,
extOrigin,
extTarget,
type,
errorRateColumn,
errorRateOutgoingColumn,
responseTimeColumn,
responseTimeOutgoingColumn,
requestRateColumn,
requestRateOutgoingColumn,
baselineRtUpper,
} = this.controller.getSettings(true).dataMapping;
for (const inputData of inputDataSets) {
const { fields } = inputData;
const externalSourceField = _.find(fields, ['name', extOrigin]);
const externalTargetField = _.find(fields, ['name', extTarget]);
const aggregationSuffixField = _.find(fields, ['name', aggregationType]);
const typeField = _.find(fields, ['name', type]);
const sourceColumnField = _.find(fields, ['name', sourceColumn]);
const targetColumnField = _.find(fields, ['name', targetColumn]);
const namespaceColumnField = _.find(fields, ['name', namespaceColumn]);
const errorRateColumnField = _.find(fields, ['name', errorRateColumn]);
const errorRateOutgoingColumnField = _.find(fields, ['name', errorRateOutgoingColumn]);
const responseTimeColumnField = _.find(fields, ['name', responseTimeColumn]);
const responseTimeOutgoingColumnField = _.find(fields, ['name', responseTimeOutgoingColumn]);
const requestRateColumnField = _.find(fields, ['name', requestRateColumn]);
const requestRateOutgoingColumnField = _.find(fields, ['name', requestRateOutgoingColumn]);
const responseTimeBaselineField = _.find(fields, ['name', baselineRtUpper]);
for (let i = 0; i < inputData.length; i++) {
const row: any = {};
row[extOrigin] = externalSourceField?.values.get(i);
row[extTarget] = externalTargetField?.values.get(i);
row[aggregationType] = aggregationSuffixField?.values.get(i);
row[sourceColumn] = sourceColumnField?.values.get(i);
row[targetColumn] = targetColumnField?.values.get(i);
row['namespace'] = namespaceColumnField?.values.get(i);
row['error_rate_in'] = errorRateColumnField?.values.get(i);
row['error_rate_out'] = errorRateOutgoingColumnField?.values.get(i);
row['response_time_in'] = responseTimeColumnField?.values.get(i);
row['response_time_out'] = responseTimeOutgoingColumnField?.values.get(i);
row['rate_in'] = requestRateColumnField?.values.get(i);
row['rate_out'] = requestRateOutgoingColumnField?.values.get(i);
row['threshold'] = responseTimeBaselineField?.values.get(i);
row['type'] = typeField?.values.get(i);
// The above code returns { "": undefined } for values that do not exist.
// These values are filtered by this line.
Object.keys(row).forEach((key) => (row[key] === undefined || row[key] === '') && delete row[key]);
rows.push(row);
}
}
return rows;
}
_resolveData(row: any) {
let source = _.has(row, 'sourceColumn') && row['sourceColumn'] !== '';
let target = _.has(row, 'targetColumn') && row['targetColumn'] !== '';
const extSource = _.has(row, 'extOrigin') && row['extOrigin'] !== '';
const extTarget = _.has(row, 'extTarget') && row['extTarget'] !== '';
let trueCount = [source, target, extSource, extTarget].filter((e) => e).length;
if (trueCount > 1) {
if (target && extTarget) {
target = false;
} else if (source && extSource) {
source = false;
} else {
console.error('source-target conflict for data element', row);
return;
}
}
let resolvedObject: any = {
data: row.data,
};
if (trueCount === 0) {
resolvedObject.target = row['aggregationSuffix'];
resolvedObject.type = GraphDataType.EXTERNAL_IN;
} else {
if (source || target) {
if (source) {
resolvedObject.source = row['sourceColumn'];
resolvedObject.target = row['aggregationSuffix'];
resolvedObject.type = GraphDataType.INTERNAL;
} else {
resolvedObject.source = row['aggregationSuffix'];
resolvedObject.target = row['targetColumn'];
resolvedObject.type = GraphDataType.INTERNAL;
}
if (resolvedObject.source === resolvedObject.target) {
resolvedObject.type = GraphDataType.SELF;
}
} else if (extSource) {
resolvedObject.source = row['externalSource'];
resolvedObject.target = row['aggregationSuffix'];
resolvedObject.type = GraphDataType.EXTERNAL_IN;
} else if (extTarget) {
resolvedObject.source = row['aggregationSuffix'];
resolvedObject.target = row['externalTarget'];
resolvedObject.type = GraphDataType.EXTERNAL_OUT;
}
}
return resolvedObject;
}
_mergeObjects(rows: any[]) {
let mergedObjects: any[] = [];
for (const row of rows) {
mergedObjects.push(row);
}
return mergedObjects;
}
processData(inputData: DataFrame[]): CurrentData {
const rows = this._dataToRows(inputData);
const flattenData = this._mergeObjects(rows);
const graphElements = this._transformObjects(flattenData);
const columnNames = this._extractColumnNames(graphElements);
const mergedData = this._mergeGraphData(graphElements);
return {
graph: mergedData,
raw: inputData,
columnNames: columnNames,
};
}
}
export default PreProcessor;
================================================
FILE: src/processing/utils/Utils.ts
================================================
import _ from 'lodash';
import { DataMapping } from '../../types';
import { ServiceDependencyGraph } from 'panel/serviceDependencyGraph/ServiceDependencyGraph';
export function isPresent<T>(t: T | undefined | null | void): t is T {
return t !== undefined && t !== null;
}
export default {
getConfig: function (graph: ServiceDependencyGraph, configName: keyof DataMapping) {
return graph.getSettings(true).dataMapping[configName];
},
};
================================================
FILE: src/types.tsx
================================================
import { DataFrame } from '@grafana/data';
export interface PanelSettings {
animate: boolean;
sumTimings: boolean;
filterEmptyConnections: boolean;
style: PanelStyleSettings;
showDebugInformation: boolean;
showConnectionStats: boolean;
icons: IconResource[];
externalIcons: IconResource[];
dataMapping: DataMa
gitextract_lgiult8p/ ├── .circleci/ │ └── config.yml ├── .codeclimate.yml ├── .config/ │ ├── .eslintrc │ ├── .prettierrc.js │ ├── Dockerfile │ ├── README.md │ ├── jest/ │ │ ├── mocks/ │ │ │ └── react-inlinesvg.tsx │ │ └── utils.js │ ├── jest-setup.js │ ├── jest.config.js │ ├── tsconfig.json │ ├── types/ │ │ └── custom.d.ts │ └── webpack/ │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .editorconfig ├── .eslintrc ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature_request.md │ │ └── question.md │ └── workflows/ │ └── build.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .release-it.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── appveyor.yml ├── docker-compose.yaml ├── docs/ │ └── README.md ├── jest-setup.js ├── jest.config.js ├── package.json ├── provisioning/ │ ├── dashboards/ │ │ └── dashboards.yml │ └── home/ │ └── home.json ├── scripts/ │ └── create-signed-plugin.sh ├── src/ │ ├── assets/ │ │ └── icons/ │ │ └── icon_index.json │ ├── css/ │ │ └── novatec-service-dependency-graph-panel.css │ ├── dummy_data_frame.ts │ ├── migration/ │ │ └── PanelMigration.tsx │ ├── module.test.ts │ ├── module.ts │ ├── options/ │ │ ├── DefaultSettings.tsx │ │ ├── TypeAheadTextfield/ │ │ │ ├── TypeaheadTextfield.css │ │ │ └── TypeaheadTextfield.tsx │ │ ├── dummyDataSwitch/ │ │ │ └── DummyDataSwitch.tsx │ │ ├── iconMapping/ │ │ │ ├── IconMapping.css │ │ │ └── IconMapping.tsx │ │ └── options.tsx │ ├── panel/ │ │ ├── PanelController.tsx │ │ ├── asset_utils.tsx │ │ ├── canvas/ │ │ │ ├── collision_detector.ts │ │ │ ├── graph_canvas.ts │ │ │ └── particle_engine.ts │ │ ├── layout_options.ts │ │ ├── serviceDependencyGraph/ │ │ │ ├── ServiceDependencyGraph.css │ │ │ └── ServiceDependencyGraph.tsx │ │ └── statistics/ │ │ ├── IncomingStatistics.tsx │ │ ├── NodeStatistics.tsx │ │ ├── SortableTable.tsx │ │ ├── Statistics.css │ │ ├── Statistics.tsx │ │ └── utils/ │ │ └── Utils.ts │ ├── plugin.json │ ├── processing/ │ │ ├── graph_generator.ts │ │ ├── node_substitutor.ts │ │ ├── node_tree.ts │ │ ├── pre_processor.ts │ │ └── utils/ │ │ └── Utils.ts │ ├── types.tsx │ └── typings/ │ └── index.d.ts └── tsconfig.json
SYMBOL INDEX (197 symbols across 23 files)
FILE: .config/jest/mocks/react-inlinesvg.tsx
type Callback (line 7) | type Callback = (...args: any[]) => void;
type StorageItem (line 9) | interface StorageItem {
constant SVG_FILE_NAME_REGEX (line 17) | const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/;
FILE: .config/webpack/constants.ts
constant SOURCE_DIR (line 1) | const SOURCE_DIR = 'src';
constant DIST_DIR (line 2) | const DIST_DIR = 'dist';
FILE: .config/webpack/utils.ts
function isWSL (line 10) | function isWSL() {
function getPackageJson (line 26) | function getPackageJson() {
function getPluginJson (line 30) | function getPluginJson() {
function hasReadme (line 34) | function hasReadme() {
function getEntries (line 40) | async function getEntries(): Promise<Record<string, string>> {
FILE: src/migration/PanelMigration.tsx
function isLegacyFormat (line 9) | function isLegacyFormat(options: any) {
function migrateIconMapping (line 17) | function migrateIconMapping(iconMappings: any) {
FILE: src/options/TypeAheadTextfield/TypeaheadTextfield.tsx
type Props (line 6) | interface Props extends StandardEditorProps<string, PanelSettings> {
type State (line 12) | interface State {
class TypeaheadTextField (line 19) | class TypeaheadTextField extends React.PureComponent<Props, State> {
method constructor (line 20) | constructor(props: Props | Readonly<Props>) {
method renderSuggestion (line 32) | renderSuggestion(suggestion: string) {
method getColumnNames (line 35) | getColumnNames() {
method render (line 89) | render() {
FILE: src/options/dummyDataSwitch/DummyDataSwitch.tsx
type Props (line 6) | interface Props extends StandardEditorProps<boolean, PanelSettings> {
type State (line 13) | interface State {
class DummyDataSwitch (line 21) | class DummyDataSwitch extends React.PureComponent<Props, State> {
method constructor (line 22) | constructor(props: Props | Readonly<Props>) {
method render (line 68) | render() {
FILE: src/options/iconMapping/IconMapping.tsx
type Props (line 7) | interface Props extends StandardEditorProps<string, PanelSettings> {
type State (line 14) | interface State {
class IconMapping (line 22) | class IconMapping extends React.PureComponent<Props, State> {
method constructor (line 23) | constructor(props: Props | Readonly<Props>) {
method addMapping (line 44) | addMapping() {
method removeMapping (line 51) | removeMapping(index: number) {
method setPatternValue (line 58) | setPatternValue(event: React.ChangeEvent<HTMLInputElement>, index: num...
method setFileNameValue (line 65) | setFileNameValue(event: ChangeEvent<HTMLSelectElement>, index: number) {
method render (line 72) | render() {
FILE: src/panel/PanelController.tsx
type Props (line 20) | interface Props extends PanelProps<PanelSettings> {}
type PanelState (line 22) | interface PanelState {
class PanelController (line 40) | class PanelController extends PureComponent<Props, PanelState> {
method constructor (line 55) | constructor(props: Props) {
method getSettings (line 66) | getSettings(resolveVariables: boolean): PanelSettings {
method resolveVariables (line 73) | resolveVariables(element: any) {
method resolveTemplateVars (line 88) | resolveTemplateVars(input: any, copy: boolean) {
method componentDidUpdate (line 105) | componentDidUpdate() {
method processQueryData (line 109) | processQueryData(data: DataFrame[]) {
method hasOnlyTableQueries (line 116) | hasOnlyTableQueries(inputData: DataFrame[]) {
method processData (line 128) | processData() {
method _transformEdges (line 138) | _transformEdges(edges: IntGraphEdge[]): CyData[] {
method _transformNodes (line 157) | _transformNodes(nodes: IntGraphNode[]): CyData[] {
method _updateOrRemove (line 179) | _updateOrRemove(dataArray: Array<NodeSingular | EdgeSingular>, inputAr...
method getError (line 197) | getError(): string | null {
method isDataAvailable (line 204) | isDataAvailable() {
method layer (line 210) | layer(layerIncrease: number) {
method render (line 222) | render() {
FILE: src/panel/asset_utils.tsx
method getAssetUrl (line 5) | getAssetUrl(assetName: string) {
method getTypeSymbol (line 10) | getTypeSymbol(type: string, externalIcons: IconResource[], resolveName =...
FILE: src/panel/canvas/collision_detector.ts
class CollisionDetector (line 4) | class CollisionDetector {
method constructor (line 7) | constructor() {
method reset (line 11) | reset() {
method addRectangle (line 15) | addRectangle(x: number, y: number, width: number, height: number) {
method isColliding (line 27) | isColliding(shape: Rectangle) {
method _intersects (line 37) | _intersects(a: Rectangle, b: Rectangle) {
method _getBottomRightCorner (line 52) | _getBottomRightCorner(rectangle: Rectangle) {
FILE: src/panel/canvas/graph_canvas.ts
class CanvasDrawer (line 26) | class CanvasDrawer {
method constructor (line 72) | constructor(ctrl: ServiceDependencyGraph, cy: cytoscape.Core, cyCanvas...
method _getTimeScale (line 95) | _getTimeScale(timeUnit: string) {
method resetAssets (line 106) | resetAssets() {
method _loadImage (line 110) | _loadImage(imageUrl: string, assetName: string) {
method _isImageLoaded (line 131) | _isImageLoaded(assetName: string) {
method _getImageAsset (line 139) | _getImageAsset(assetName: string, resolveName = true) {
method _getAsset (line 153) | _getAsset(assetName: string, relativeUrl: string) {
method start (line 166) | start() {
method startAnimation (line 181) | startAnimation() {
method stopAnimation (line 185) | stopAnimation() {
method _skipFrame (line 190) | _skipFrame() {
method repaint (line 204) | repaint(forceRepaint = false) {
method _setTransformation (line 256) | _setTransformation(ctx: CanvasRenderingContext2D) {
method _drawEdgeAnimation (line 264) | _drawEdgeAnimation(ctx: CanvasRenderingContext2D) {
method _drawEdges (line 282) | _drawEdges(ctx: CanvasRenderingContext2D, edges: cytoscape.EdgeSingula...
method _drawEdgeLine (line 300) | _drawEdgeLine(
method _drawEdgeLabel (line 338) | _drawEdgeLabel(ctx: CanvasRenderingContext2D, edge: cytoscape.EdgeSing...
method _drawEdgeParticles (line 372) | _drawEdgeParticles(
method _drawLabel (line 435) | _drawLabel(ctx: CanvasRenderingContext2D, label: string, cX: number, c...
method _getNextPointOnVector (line 472) | _getNextPointOnVector(x: number, y: number, edge: cytoscape.EdgeSingul...
method _drawParticle (line 484) | _drawParticle(drawCtx: DrawContext, particles: Particle[], index: numb...
method _drawNodes (line 503) | _drawNodes(ctx: CanvasRenderingContext2D) {
method _drawNode (line 536) | _drawNode(ctx: CanvasRenderingContext2D, node: cytoscape.NodeSingular) {
method _drawServiceIcon (line 585) | _drawServiceIcon(ctx: CanvasRenderingContext2D, node: cytoscape.NodeSi...
method _drawNodeStatistics (line 609) | _drawNodeStatistics(ctx: CanvasRenderingContext2D, node: cytoscape.Nod...
method _drawThresholdStroke (line 646) | _drawThresholdStroke(
method _drawExternalService (line 692) | _drawExternalService(ctx: CanvasRenderingContext2D, node: cytoscape.No...
method _drawNodeLabel (line 716) | _drawNodeLabel(ctx: CanvasRenderingContext2D, node: cytoscape.NodeSing...
method _drawDebugInformation (line 757) | _drawDebugInformation() {
method _drawDonut (line 768) | _drawDonut(
method _drawArc (line 809) | _drawArc(
FILE: src/panel/canvas/particle_engine.ts
class ParticleEngine (line 5) | class ParticleEngine {
method constructor (line 16) | constructor(canvasDrawer: CanvasDrawer) {
method start (line 21) | start() {
method stop (line 29) | stop() {
method animate (line 33) | animate() {
method hasParticles (line 46) | hasParticles() {
method _spawnParticles (line 58) | _spawnParticles() {
method count (line 108) | count() {
FILE: src/panel/serviceDependencyGraph/ServiceDependencyGraph.tsx
type PanelState (line 22) | interface PanelState {
class ServiceDependencyGraph (line 41) | class ServiceDependencyGraph extends PureComponent<PanelState, PanelStat...
method constructor (line 60) | constructor(props: PanelState) {
method componentDidMount (line 79) | componentDidMount() {
method componentDidUpdate (line 136) | componentDidUpdate() {
method _updateGraph (line 140) | _updateGraph(graph: IntGraph) {
method _transformNodes (line 172) | _transformNodes(nodes: IntGraphNode[]): ElementDefinition[] {
method _transformEdges (line 193) | _transformEdges(edges: IntGraphEdge[]): ElementDefinition[] {
method _updateOrRemove (line 213) | _updateOrRemove(dataArray: Array<NodeSingular | EdgeSingular>, inputAr...
method onSelectionChange (line 231) | onSelectionChange() {
method getSettings (line 246) | getSettings(resolveVariables: boolean): PanelSettings {
method toggleAnimation (line 250) | toggleAnimation() {
method runLayout (line 265) | runLayout(unlockNodes = false) {
method unlockNodes (line 283) | unlockNodes() {
method fit (line 289) | fit() {
method zoom (line 301) | zoom(zoom: number) {
method updateStatisticTable (line 311) | updateStatisticTable() {
method generateDrillDownLink (line 393) | generateDrillDownLink() {
method render (line 401) | render() {
FILE: src/panel/statistics/NodeStatistics.tsx
type NodeStatisticsProps (line 7) | interface NodeStatisticsProps {
function getStatisticsTable (line 20) | function getStatisticsTable(noDataText: string, nodeList: TableContent[]) {
FILE: src/panel/statistics/SortableTable.tsx
type SortableTableProps (line 5) | interface SortableTableProps {
function sort (line 10) | function sort(a: string, b: string, order: string, ignoreLiteral: string) {
FILE: src/panel/statistics/Statistics.tsx
type StatisticsProps (line 8) | interface StatisticsProps {
FILE: src/panel/statistics/utils/Utils.ts
function roundPercentageToDecimal (line 1) | function roundPercentageToDecimal(decimal: number, value: string) {
function _getDecimalsOf (line 11) | function _getDecimalsOf(value: number) {
FILE: src/processing/graph_generator.ts
class GraphGenerator (line 16) | class GraphGenerator {
method constructor (line 20) | constructor(controller: PanelController) {
method _createNode (line 25) | _createNode(dataElements: GraphDataElement[], nodeTree: NodeTree): Int...
method _createMissingNodes (line 121) | _createMissingNodes(data: GraphDataElement[], nodes: IntGraphNode[]): ...
method _createNodes (line 158) | _createNodes(data: GraphDataElement[]): IntGraphNode[] {
method _resolveSubstitute (line 179) | _resolveSubstitute(name: string): string {
method _createEdge (line 187) | _createEdge(dataElement: GraphDataElement): IntGraphEdge | undefined {
method _resolveEdgeMap (line 234) | _resolveEdgeMap(edges: IntGraphEdge[]) {
method _mergeArrayOfEdges (line 246) | _mergeArrayOfEdges(edges: IntGraphEdge[]) {
method _edgeMapToMergedEdges (line 322) | _edgeMapToMergedEdges(edgeMap: Map<string, IntGraphEdge[]>) {
method _mergeEdges (line 330) | _mergeEdges(edges: IntGraphEdge[]) {
method _createEdges (line 337) | _createEdges(data: GraphDataElement[]): IntGraphEdge[] {
method _filterData (line 349) | _filterData(graph: IntGraph): IntGraph {
method generateGraph (line 392) | generateGraph(graphData: GraphDataElement[]): IntGraph {
method _updateMaxLayer (line 405) | _updateMaxLayer(layer: number) {
FILE: src/processing/node_substitutor.ts
class NodeSubstitutor (line 4) | class NodeSubstitutor {
method constructor (line 7) | constructor() {
method add (line 11) | add(node: IntGraphNode) {
method substituteUntilLayer (line 20) | substituteUntilLayer(nodeName: string, layer: number, maxLayer: number) {
FILE: src/processing/node_tree.ts
class NodeTree (line 4) | class NodeTree {
method constructor (line 8) | constructor() {
method addNode (line 13) | addNode(node: IntGraphNode) {
method getNodesFromLayer (line 19) | getNodesFromLayer(layer: number) {
method getNamePath (line 31) | getNamePath(namePath: string[]) {
method _getNodesFromLayer (line 39) | private _getNodesFromLayer(currentNode: NodeTreeElement, layer: number...
method _getNameSpaceFromCurrentLevel (line 56) | private _getNameSpaceFromCurrentLevel(namespace: string[], currentLeve...
method _sumMetrics (line 64) | private _sumMetrics(sourceNode: IntGraphNode, targetNode: IntGraphNode...
method _getObjectFromArray (line 119) | private _getObjectFromArray(array: NodeTreeElement[], id: string) {
method _addNode (line 128) | private _addNode(nodeToAdd: NodeTreeElement, currentLayerNode: NodeTre...
FILE: src/processing/pre_processor.ts
class PreProcessor (line 6) | class PreProcessor {
method constructor (line 9) | constructor(controller: PanelController) {
method _transformObjects (line 13) | _transformObjects(data: any[]): GraphDataElement[] {
method _mergeGraphData (line 91) | _mergeGraphData(data: GraphDataElement[]): GraphDataElement[] {
method _cleanMetaData (line 103) | _cleanMetaData(columnMapping: any, metaData: any) {
method _extractColumnNames (line 115) | _extractColumnNames(data: GraphDataElement[]): string[] {
method _getField (line 125) | _getField(fieldName: string, fields: any[]) {
method _mergeSeries (line 134) | _mergeSeries(series: any[]) {
method _dataToRows (line 153) | _dataToRows(inputDataSets: any) {
method _resolveData (line 218) | _resolveData(row: any) {
method _mergeObjects (line 269) | _mergeObjects(rows: any[]) {
method processData (line 278) | processData(inputData: DataFrame[]): CurrentData {
FILE: src/processing/utils/Utils.ts
function isPresent (line 5) | function isPresent<T>(t: T | undefined | null | void): t is T {
FILE: src/types.tsx
type PanelSettings (line 3) | interface PanelSettings {
type DataMapping (line 18) | interface DataMapping {
type PanelStyleSettings (line 40) | interface PanelStyleSettings {
type IconResource (line 46) | interface IconResource {
type QueryResponseColumn (line 51) | interface QueryResponseColumn {
type CyData (line 56) | interface CyData {
type CurrentData (line 71) | interface CurrentData {
type GraphDataElement (line 77) | interface GraphDataElement {
type DataElement (line 85) | interface DataElement {
type GraphDataType (line 96) | enum GraphDataType {
type IntGraph (line 103) | interface IntGraph {
type IntGraphNode (line 108) | interface IntGraphNode {
type IntGraphNodeData (line 112) | interface IntGraphNodeData {
type IntGraphMetrics (line 123) | interface IntGraphMetrics {
type EnGraphNodeType (line 131) | enum EnGraphNodeType {
type IntGraphEdge (line 137) | interface IntGraphEdge {
type IntGraphEdgeData (line 144) | interface IntGraphEdgeData {
type NodeTreeElement (line 150) | interface NodeTreeElement {
type Particle (line 156) | interface Particle {
type Particles (line 161) | interface Particles {
type TableContent (line 166) | interface TableContent {
type IntSelectionStatistics (line 173) | interface IntSelectionStatistics {
type CyCanvas (line 181) | interface CyCanvas {
type IntTableHeader (line 188) | interface IntTableHeader {
type NodeData (line 200) | interface NodeData {
type ScaleValue (line 207) | interface ScaleValue {
type DrawContext (line 212) | interface DrawContext {
type Rectangle (line 224) | interface Rectangle {
type Point (line 230) | interface Point {
Condensed preview — 74 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (207K chars).
[
{
"path": ".circleci/config.yml",
"chars": 1758,
"preview": "version: 2.1\n\nparameters:\n ssh-fingerprint:\n type: string\n default: ${GITHUB_SSH_FINGERPRINT}\n\naliases:\n # Workf"
},
{
"path": ".codeclimate.yml",
"chars": 126,
"preview": "exclude_patterns:\n- \"dist\"\n- \"**/node_modules/\"\n- \"**/*.d.ts\"\n- \"src/libs/\"\n- \"src/images/\"\n- \"src/img/\"\n- \"src/screensh"
},
{
"path": ".config/.eslintrc",
"chars": 407,
"preview": "/*\n * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️\n *\n * In order"
},
{
"path": ".config/.prettierrc.js",
"chars": 385,
"preview": "/*\n * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️\n *\n * In order"
},
{
"path": ".config/Dockerfile",
"chars": 725,
"preview": "ARG grafana_version=latest\nARG grafana_image=grafana-enterprise\n\nFROM grafana/${grafana_image}:${grafana_version}\n\n# Mak"
},
{
"path": ".config/README.md",
"chars": 5092,
"preview": "# Default build configuration by Grafana\n\n**This is an auto-generated directory and is not intended to be changed! ⚠️**\n"
},
{
"path": ".config/jest/mocks/react-inlinesvg.tsx",
"chars": 857,
"preview": "// Due to the grafana/ui Icon component making fetch requests to\n// `/public/img/icon/<icon_name>.svg` we need to mock r"
},
{
"path": ".config/jest/utils.js",
"chars": 867,
"preview": "/*\n * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️\n *\n * In order"
},
{
"path": ".config/jest-setup.js",
"chars": 848,
"preview": "/*\n * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️\n *\n * In order"
},
{
"path": ".config/jest.config.js",
"chars": 1465,
"preview": "/*\n * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️\n *\n * In order"
},
{
"path": ".config/tsconfig.json",
"chars": 741,
"preview": "/*\n * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️\n *\n * In order"
},
{
"path": ".config/types/custom.d.ts",
"chars": 602,
"preview": "// Image declarations\ndeclare module '*.gif' {\n const src: string;\n export default src;\n}\n\ndeclare module '*.jpg' {\n "
},
{
"path": ".config/webpack/constants.ts",
"chars": 65,
"preview": "export const SOURCE_DIR = 'src';\nexport const DIST_DIR = 'dist';\n"
},
{
"path": ".config/webpack/utils.ts",
"chars": 1710,
"preview": "import fs from 'fs';\nimport process from 'process';\nimport os from 'os';\nimport path from 'path';\nimport util from 'util"
},
{
"path": ".config/webpack/webpack.config.ts",
"chars": 6477,
"preview": "/*\n * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️\n *\n * In order"
},
{
"path": ".editorconfig",
"chars": 261,
"preview": "# http://editorconfig.org\nroot = true\n\n[*]\nindent_style = space\nindent_size = 2\ncharset = utf-8\ntrim_trailing_whitespace"
},
{
"path": ".eslintrc",
"chars": 38,
"preview": "{\n \"extends\": \"./.config/.eslintrc\"\n}"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 803,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"[BUG] - *ENTER TITLE*\"\nlabels: bug\nassignees: ''\n"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 629,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[Feature] - *ENTER TITLE*\"\nlabels: enhancement"
},
{
"path": ".github/ISSUE_TEMPLATE/question.md",
"chars": 227,
"preview": "---\nname: Question\nabout: Ask a question about the project.\ntitle: \"[Question] - *ENTER TITLE*\"\nlabels: question\nassigne"
},
{
"path": ".github/workflows/build.yml",
"chars": 261,
"preview": "name: Build plugin\n\non:\n push:\n\njobs:\n build:\n runs-on: ubuntu-latest\n container:\n image: node:20.19\n st"
},
{
"path": ".gitignore",
"chars": 54,
"preview": "node_modules\nnpm-debug.log\n*.log\n.vscode\ndist\ncoverage"
},
{
"path": ".nvmrc",
"chars": 2,
"preview": "16"
},
{
"path": ".prettierrc.js",
"chars": 122,
"preview": "module.exports = {\n // Prettier configuration provided by Grafana scaffolding\n ...require(\"./.config/.prettierrc.js\")\n"
},
{
"path": ".release-it.json",
"chars": 333,
"preview": "{\n \"github\": {\n \"release\": false\n },\n \"npm\": {\n \"publish\": false\n },\n \"hooks\": {\n \"after:b"
},
{
"path": "CHANGELOG.md",
"chars": 760,
"preview": "# Change Log\n\nAll notable changes to this project will be documented in this file.\n\n## v4.2.0\n\nUpdate dependencies\n\n## v"
},
{
"path": "CONTRIBUTING.md",
"chars": 1441,
"preview": "# Contributing\n\n## Releasing\n\nTo create a new release of the plugin, follow these steps.\n\nYou may also read the [officia"
},
{
"path": "LICENSE",
"chars": 10768,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "README.md",
"chars": 10040,
"preview": "## Novatec Service Dependency Graph Panel\n\n => {\n it('should return true', () => {\n expect(true).toBeTruthy("
},
{
"path": "src/module.ts",
"chars": 419,
"preview": "import { PanelPlugin } from '@grafana/data';\nimport { PanelSettings } from './types';\n\nimport { PanelController } from '"
},
{
"path": "src/options/DefaultSettings.tsx",
"chars": 1448,
"preview": "import { PanelSettings } from '../types';\n\nexport const DefaultSettings: PanelSettings = {\n animate: true,\n\n dataMappi"
},
{
"path": "src/options/TypeAheadTextfield/TypeaheadTextfield.css",
"chars": 352,
"preview": ".service-dependency-graph-panel .suggestion {\n width: 100%;\n border-right: 1px solid #3865AB ;\n border-left: 1p"
},
{
"path": "src/options/TypeAheadTextfield/TypeaheadTextfield.tsx",
"chars": 3407,
"preview": "import React from 'react';\nimport Autosuggest, { InputProps } from 'react-autosuggest';\nimport { StandardEditorContext, "
},
{
"path": "src/options/dummyDataSwitch/DummyDataSwitch.tsx",
"chars": 2272,
"preview": "import React from 'react';\nimport { StandardEditorContext, StandardEditorProps } from '@grafana/data';\nimport { PanelSet"
},
{
"path": "src/options/iconMapping/IconMapping.css",
"chars": 416,
"preview": ".service-dependency-graph-panel .no-background {\n background-color: transparent;\n}\n\n.service-dependency-graph-panel ."
},
{
"path": "src/options/iconMapping/IconMapping.tsx",
"chars": 4200,
"preview": "import React, { ChangeEvent } from 'react';\nimport { StandardEditorContext, StandardEditorProps } from '@grafana/data';\n"
},
{
"path": "src/options/options.tsx",
"chars": 9258,
"preview": "import { PanelOptionsEditorBuilder } from '@grafana/data';\nimport { PanelSettings } from '../types';\nimport { TypeaheadT"
},
{
"path": "src/panel/PanelController.tsx",
"chars": 6804,
"preview": "import React, { LegacyRef, PureComponent } from 'react';\nimport {\n AbsoluteTimeRange,\n DataFrame,\n FieldConfigSource,"
},
{
"path": "src/panel/asset_utils.tsx",
"chars": 712,
"preview": "import { find } from 'lodash';\nimport { IconResource } from 'types';\n\nexport default {\n getAssetUrl(assetName: string) "
},
{
"path": "src/panel/canvas/collision_detector.ts",
"chars": 1425,
"preview": "import _ from 'lodash';\nimport { Point, Rectangle } from 'types';\n\nexport default class CollisionDetector {\n blockedAre"
},
{
"path": "src/panel/canvas/graph_canvas.ts",
"chars": 24089,
"preview": "import _ from 'lodash';\nimport cytoscape from 'cytoscape';\nimport { ServiceDependencyGraph } from '../serviceDependencyG"
},
{
"path": "src/panel/canvas/particle_engine.ts",
"chars": 2813,
"preview": "import CanvasDrawer from './graph_canvas';\nimport _ from 'lodash';\nimport { Particles, Particle, IntGraphMetrics } from "
},
{
"path": "src/panel/layout_options.ts",
"chars": 2556,
"preview": "const options = {\n name: 'cola',\n animate: true, // whether to show the layout as it's running\n refresh: 1, // number"
},
{
"path": "src/panel/serviceDependencyGraph/ServiceDependencyGraph.css",
"chars": 374,
"preview": ".service-dependency-graph-panel .graph-container {\n width: 100%;\n height: 100%;\n display: flex;\n flex-direct"
},
{
"path": "src/panel/serviceDependencyGraph/ServiceDependencyGraph.tsx",
"chars": 12062,
"preview": "import CanvasDrawer from 'panel/canvas/graph_canvas';\nimport cytoscape, { EdgeCollection, EdgeSingular, ElementDefinitio"
},
{
"path": "src/panel/statistics/IncomingStatistics.tsx",
"chars": 1329,
"preview": "import React from 'react';\nimport { TableContent } from 'types';\n\nexport const NodeStatistics = (receiving: TableContent"
},
{
"path": "src/panel/statistics/NodeStatistics.tsx",
"chars": 1443,
"preview": "import React from 'react';\nimport { IntTableHeader } from '../../types';\nimport { TableContent } from 'types';\nimport So"
},
{
"path": "src/panel/statistics/SortableTable.tsx",
"chars": 1328,
"preview": "import React from 'react';\nimport { IntTableHeader, NodeData } from '../../types';\nimport BootstrapTable from 'react-boo"
},
{
"path": "src/panel/statistics/Statistics.css",
"chars": 297,
"preview": ".service-dependency-graph-panel .margin {\n margin: 10px;\n}\n\n.service-dependency-graph-panel .statistics {\n flex-ba"
},
{
"path": "src/panel/statistics/Statistics.tsx",
"chars": 3879,
"preview": "import React from 'react';\nimport { NodeStatistics } from './NodeStatistics';\nimport '../../css/novatec-service-dependen"
},
{
"path": "src/panel/statistics/utils/Utils.ts",
"chars": 463,
"preview": "function roundPercentageToDecimal(decimal: number, value: string) {\n if (value !== '-') {\n let valueDecimals = _getD"
},
{
"path": "src/plugin.json",
"chars": 1471,
"preview": "{\n \"type\": \"panel\",\n \"name\": \"Service Dependency Graph\",\n \"id\": \"novatec-sdg-panel\",\n \"info\": {\n \"version\": \"4.2."
},
{
"path": "src/processing/graph_generator.ts",
"chars": 13386,
"preview": "import _ from 'lodash';\nimport { isPresent } from './utils/Utils';\nimport { PanelController } from '../panel/PanelContro"
},
{
"path": "src/processing/node_substitutor.ts",
"chars": 825,
"preview": "import _ from 'lodash';\nimport { IntGraphNode } from '../types';\n\nclass NodeSubstitutor {\n private _substitutionMap: an"
},
{
"path": "src/processing/node_tree.ts",
"chars": 6813,
"preview": "import _ from 'lodash';\nimport { EnGraphNodeType, IntGraphMetrics, IntGraphNode, NodeTreeElement } from '../types';\n\ncla"
},
{
"path": "src/processing/pre_processor.ts",
"chars": 10093,
"preview": "import { DataFrame } from '@grafana/data';\nimport _ from 'lodash';\nimport { PanelController } from '../panel/PanelContro"
},
{
"path": "src/processing/utils/Utils.ts",
"chars": 448,
"preview": "import _ from 'lodash';\nimport { DataMapping } from '../../types';\nimport { ServiceDependencyGraph } from 'panel/service"
},
{
"path": "src/types.tsx",
"chars": 4485,
"preview": "import { DataFrame } from '@grafana/data';\n\nexport interface PanelSettings {\n animate: boolean;\n sumTimings: boolean;\n"
},
{
"path": "src/typings/index.d.ts",
"chars": 180,
"preview": "declare module 'cytoscape-canvas';\ndeclare module 'cytoscape-cola';\ndeclare module 'human-format';\ndeclare module 'react"
},
{
"path": "tsconfig.json",
"chars": 273,
"preview": "{\n \"extends\": \"./.config/tsconfig.json\",\n \"include\": [\"src\", \"types\"],\n \"jsx\": \"react\",\n \"compilerOptions\": {\n \"s"
}
]
About this extraction
This page contains the full source code of the NovatecConsulting/novatec-service-dependency-graph-panel GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 74 files (190.4 KB), approximately 49.2k tokens, and a symbol index with 197 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.