[APM] Service Map Layout (#59020)
* Addresses #55544. - uses the core breadthfirst cytoscape layout - rotates elements by -90degrees - selects rum nodes as roots - implements hover styles to show connected nodes - fixes flash of unstyled cytoscape elements on initial load * PR review feedback * adds canned response for testing cytoscape layout in storybook * update dep snapshot for removing cytoscape-dagre
This commit is contained in:
parent
b12ef02cc4
commit
5539d6955f
|
@ -55,7 +55,7 @@ addParameters({
|
|||
brandTitle: 'Kibana Storybook',
|
||||
brandUrl: 'https://github.com/elastic/kibana/tree/master/packages/kbn-storybook',
|
||||
}),
|
||||
showPanel: true,
|
||||
showPanel: false,
|
||||
isFullscreen: false,
|
||||
panelPosition: 'bottom',
|
||||
isToolshown: true,
|
||||
|
|
|
@ -9,8 +9,12 @@ import { storiesOf } from '@storybook/react';
|
|||
import cytoscape from 'cytoscape';
|
||||
import React from 'react';
|
||||
import { Cytoscape } from './Cytoscape';
|
||||
import { getCytoscapeElements } from './get_cytoscape_elements';
|
||||
import serviceMapResponse from './cytoscape-layout-test-response.json';
|
||||
import { iconForNode } from './icons';
|
||||
|
||||
const elementsFromResponses = getCytoscapeElements([serviceMapResponse], '');
|
||||
|
||||
storiesOf('app/ServiceMap/Cytoscape', module).add(
|
||||
'example',
|
||||
() => {
|
||||
|
@ -49,11 +53,13 @@ storiesOf('app/ServiceMap/Cytoscape', module).add(
|
|||
}
|
||||
];
|
||||
const height = 300;
|
||||
const width = 1340;
|
||||
const serviceName = 'opbeans-python';
|
||||
return (
|
||||
<Cytoscape
|
||||
elements={elements}
|
||||
height={height}
|
||||
width={width}
|
||||
serviceName={serviceName}
|
||||
/>
|
||||
);
|
||||
|
@ -66,114 +72,137 @@ storiesOf('app/ServiceMap/Cytoscape', module).add(
|
|||
}
|
||||
);
|
||||
|
||||
storiesOf('app/ServiceMap/Cytoscape', module).add(
|
||||
'node icons',
|
||||
() => {
|
||||
const cy = cytoscape();
|
||||
const elements = [
|
||||
{ data: { id: 'default', label: 'default', type: undefined } },
|
||||
{ data: { id: 'cache', label: 'cache', type: 'cache' } },
|
||||
{ data: { id: 'database', label: 'database', type: 'database' } },
|
||||
{ data: { id: 'external', label: 'external', type: 'external' } },
|
||||
{ data: { id: 'messaging', label: 'messaging', type: 'messaging' } },
|
||||
storiesOf('app/ServiceMap/Cytoscape', module)
|
||||
.add(
|
||||
'node icons',
|
||||
() => {
|
||||
const cy = cytoscape();
|
||||
const elements = [
|
||||
{ data: { id: 'default', label: 'default', type: undefined } },
|
||||
{ data: { id: 'cache', label: 'cache', type: 'cache' } },
|
||||
{ data: { id: 'database', label: 'database', type: 'database' } },
|
||||
{ data: { id: 'external', label: 'external', type: 'external' } },
|
||||
{ data: { id: 'messaging', label: 'messaging', type: 'messaging' } },
|
||||
|
||||
{
|
||||
data: {
|
||||
id: 'dotnet',
|
||||
label: 'dotnet service',
|
||||
type: 'service',
|
||||
agentName: 'dotnet'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'go',
|
||||
label: 'go service',
|
||||
type: 'service',
|
||||
agentName: 'go'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'java',
|
||||
label: 'java service',
|
||||
type: 'service',
|
||||
agentName: 'java'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'js-base',
|
||||
label: 'js-base service',
|
||||
type: 'service',
|
||||
agentName: 'js-base'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'nodejs',
|
||||
label: 'nodejs service',
|
||||
type: 'service',
|
||||
agentName: 'nodejs'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'php',
|
||||
label: 'php service',
|
||||
type: 'service',
|
||||
agentName: 'php'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'python',
|
||||
label: 'python service',
|
||||
type: 'service',
|
||||
agentName: 'python'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'ruby',
|
||||
label: 'ruby service',
|
||||
type: 'service',
|
||||
agentName: 'ruby'
|
||||
{
|
||||
data: {
|
||||
id: 'dotnet',
|
||||
label: 'dotnet service',
|
||||
type: 'service',
|
||||
agentName: 'dotnet'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'go',
|
||||
label: 'go service',
|
||||
type: 'service',
|
||||
agentName: 'go'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'java',
|
||||
label: 'java service',
|
||||
type: 'service',
|
||||
agentName: 'java'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'js-base',
|
||||
label: 'js-base service',
|
||||
type: 'service',
|
||||
agentName: 'js-base'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'nodejs',
|
||||
label: 'nodejs service',
|
||||
type: 'service',
|
||||
agentName: 'nodejs'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'php',
|
||||
label: 'php service',
|
||||
type: 'service',
|
||||
agentName: 'php'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'python',
|
||||
label: 'python service',
|
||||
type: 'service',
|
||||
agentName: 'python'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'ruby',
|
||||
label: 'ruby service',
|
||||
type: 'service',
|
||||
agentName: 'ruby'
|
||||
}
|
||||
}
|
||||
];
|
||||
cy.add(elements);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="l" wrap={true}>
|
||||
{cy.nodes().map(node => (
|
||||
<EuiFlexItem key={node.data('id')}>
|
||||
<EuiCard
|
||||
description={
|
||||
<pre>
|
||||
agentName: {node.data('agentName') || 'undefined'}, type:{' '}
|
||||
{node.data('type') || 'undefined'}
|
||||
</pre>
|
||||
}
|
||||
icon={
|
||||
<img
|
||||
alt={node.data('label')}
|
||||
src={iconForNode(node)}
|
||||
height={80}
|
||||
width={80}
|
||||
/>
|
||||
}
|
||||
title={node.data('label')}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
{
|
||||
info: {
|
||||
propTables: false,
|
||||
source: false
|
||||
}
|
||||
];
|
||||
cy.add(elements);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="l" wrap={true}>
|
||||
{cy.nodes().map(node => (
|
||||
<EuiFlexItem key={node.data('id')}>
|
||||
<EuiCard
|
||||
description={
|
||||
<pre>
|
||||
agentName: {node.data('agentName') || 'undefined'}, type:{' '}
|
||||
{node.data('type') || 'undefined'}
|
||||
</pre>
|
||||
}
|
||||
icon={
|
||||
<img
|
||||
alt={node.data('label')}
|
||||
src={iconForNode(node)}
|
||||
height={80}
|
||||
width={80}
|
||||
/>
|
||||
}
|
||||
title={node.data('label')}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
{
|
||||
info: {
|
||||
propTables: false,
|
||||
source: false
|
||||
}
|
||||
}
|
||||
);
|
||||
)
|
||||
.add(
|
||||
'layout',
|
||||
() => {
|
||||
const height = 640;
|
||||
const width = 1340;
|
||||
const serviceName = undefined; // global service map
|
||||
return (
|
||||
<Cytoscape
|
||||
elements={elementsFromResponses}
|
||||
height={height}
|
||||
width={width}
|
||||
serviceName={serviceName}
|
||||
/>
|
||||
);
|
||||
},
|
||||
{
|
||||
info: {
|
||||
source: false
|
||||
}
|
||||
}
|
||||
)
|
||||
.addParameters({ options: { showPanel: false } });
|
||||
|
|
|
@ -10,13 +10,16 @@ import React, {
|
|||
useRef,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
createContext
|
||||
createContext,
|
||||
useCallback
|
||||
} from 'react';
|
||||
import cytoscape from 'cytoscape';
|
||||
import dagre from 'cytoscape-dagre';
|
||||
import { cytoscapeOptions } from './cytoscapeOptions';
|
||||
|
||||
cytoscape.use(dagre);
|
||||
import { isRumAgentName } from '../../../../../../../plugins/apm/common/agent_name';
|
||||
import {
|
||||
cytoscapeOptions,
|
||||
nodeHeight,
|
||||
animationOptions
|
||||
} from './cytoscapeOptions';
|
||||
|
||||
export const CytoscapeContext = createContext<cytoscape.Core | undefined>(
|
||||
undefined
|
||||
|
@ -26,6 +29,7 @@ interface CytoscapeProps {
|
|||
children?: ReactNode;
|
||||
elements: cytoscape.ElementDefinition[];
|
||||
height: number;
|
||||
width: number;
|
||||
serviceName?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
@ -52,19 +56,83 @@ function useCytoscape(options: cytoscape.CytoscapeOptions) {
|
|||
return [ref, cy] as [React.MutableRefObject<any>, cytoscape.Core | undefined];
|
||||
}
|
||||
|
||||
function getLayoutOptions(
|
||||
selectedRoots: string[],
|
||||
height: number,
|
||||
width: number
|
||||
): cytoscape.LayoutOptions {
|
||||
return {
|
||||
name: 'breadthfirst',
|
||||
roots: selectedRoots,
|
||||
fit: true,
|
||||
padding: nodeHeight,
|
||||
spacingFactor: 0.85,
|
||||
animate: true,
|
||||
animationEasing: animationOptions.easing,
|
||||
animationDuration: animationOptions.duration,
|
||||
// Rotate nodes from top -> bottom to display left -> right
|
||||
// @ts-ignore
|
||||
transform: (node: any, { x, y }: cytoscape.Position) => ({ x: y, y: -x }),
|
||||
// swap width/height of boundingBox to compensation for the rotation
|
||||
boundingBox: { x1: 0, y1: 0, w: height, h: width }
|
||||
};
|
||||
}
|
||||
|
||||
function selectRoots(elements: cytoscape.ElementDefinition[]): string[] {
|
||||
const nodes = cytoscape({ elements }).nodes();
|
||||
const unconnectedNodes = nodes.roots().intersection(nodes.leaves());
|
||||
const rumNodes = nodes.filter(node => isRumAgentName(node.data('agentName')));
|
||||
return rumNodes.union(unconnectedNodes).map(node => node.id());
|
||||
}
|
||||
|
||||
export function Cytoscape({
|
||||
children,
|
||||
elements,
|
||||
height,
|
||||
width,
|
||||
serviceName,
|
||||
style
|
||||
}: CytoscapeProps) {
|
||||
const [ref, cy] = useCytoscape({ ...cytoscapeOptions, elements });
|
||||
const initialElements = elements.map(element => ({
|
||||
...element,
|
||||
// prevents flash of unstyled elements
|
||||
classes: [element.classes, 'invisible'].join(' ').trim()
|
||||
}));
|
||||
|
||||
const [ref, cy] = useCytoscape({
|
||||
...cytoscapeOptions,
|
||||
elements: initialElements
|
||||
});
|
||||
|
||||
// Add the height to the div style. The height is a separate prop because it
|
||||
// is required and can trigger rendering when changed.
|
||||
const divStyle = { ...style, height };
|
||||
|
||||
const dataHandler = useCallback<cytoscape.EventHandler>(
|
||||
event => {
|
||||
if (cy) {
|
||||
// Add the "primary" class to the node if its id matches the serviceName.
|
||||
if (cy.nodes().length > 0 && serviceName) {
|
||||
cy.nodes().removeClass('primary');
|
||||
cy.getElementById(serviceName).addClass('primary');
|
||||
}
|
||||
|
||||
if (event.cy.elements().length > 0) {
|
||||
const selectedRoots = selectRoots(elements);
|
||||
const layout = cy.layout(
|
||||
getLayoutOptions(selectedRoots, height, width)
|
||||
);
|
||||
layout.one('layoutstop', () => {
|
||||
// show elements after layout is applied
|
||||
cy.elements().removeClass('invisible');
|
||||
});
|
||||
layout.run();
|
||||
}
|
||||
}
|
||||
},
|
||||
[cy, serviceName, elements, height, width]
|
||||
);
|
||||
|
||||
// Trigger a custom "data" event when data changes
|
||||
useEffect(() => {
|
||||
if (cy) {
|
||||
|
@ -75,19 +143,6 @@ export function Cytoscape({
|
|||
|
||||
// Set up cytoscape event handlers
|
||||
useEffect(() => {
|
||||
const dataHandler: cytoscape.EventHandler = event => {
|
||||
if (cy) {
|
||||
// Add the "primary" class to the node if its id matches the serviceName.
|
||||
if (cy.nodes().length > 0 && serviceName) {
|
||||
cy.nodes().removeClass('primary');
|
||||
cy.getElementById(serviceName).addClass('primary');
|
||||
}
|
||||
|
||||
if (event.cy.elements().length > 0) {
|
||||
cy.layout(cytoscapeOptions.layout as cytoscape.LayoutOptions).run();
|
||||
}
|
||||
}
|
||||
};
|
||||
const mouseoverHandler: cytoscape.EventHandler = event => {
|
||||
event.target.addClass('hover');
|
||||
event.target.connectedEdges().addClass('nodeHover');
|
||||
|
@ -99,18 +154,23 @@ export function Cytoscape({
|
|||
|
||||
if (cy) {
|
||||
cy.on('data', dataHandler);
|
||||
cy.ready(dataHandler);
|
||||
cy.on('mouseover', 'edge, node', mouseoverHandler);
|
||||
cy.on('mouseout', 'edge, node', mouseoutHandler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (cy) {
|
||||
cy.removeListener('data', undefined, dataHandler);
|
||||
cy.removeListener(
|
||||
'data',
|
||||
undefined,
|
||||
dataHandler as cytoscape.EventHandler
|
||||
);
|
||||
cy.removeListener('mouseover', 'edge, node', mouseoverHandler);
|
||||
cy.removeListener('mouseout', 'edge, node', mouseoutHandler);
|
||||
}
|
||||
};
|
||||
}, [cy, serviceName]);
|
||||
}, [cy, dataHandler, serviceName]);
|
||||
|
||||
return (
|
||||
<CytoscapeContext.Provider value={cy}>
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -15,17 +15,6 @@ export const animationOptions: cytoscape.AnimationOptions = {
|
|||
const lineColor = '#C5CCD7';
|
||||
export const nodeHeight = parseInt(theme.avatarSizing.l.size, 10);
|
||||
|
||||
const layout = {
|
||||
name: 'dagre',
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
rankDir: 'LR',
|
||||
animate: true,
|
||||
animationEasing: animationOptions.easing,
|
||||
animationDuration: animationOptions.duration,
|
||||
fit: true,
|
||||
padding: nodeHeight
|
||||
};
|
||||
|
||||
function isService(el: cytoscape.NodeSingular) {
|
||||
return el.data('type') === 'service';
|
||||
}
|
||||
|
@ -79,7 +68,9 @@ const style: cytoscape.Stylesheet[] = [
|
|||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'curve-style': 'bezier',
|
||||
'curve-style': 'taxi',
|
||||
// @ts-ignore
|
||||
'taxi-direction': 'rightward',
|
||||
'line-color': lineColor,
|
||||
'overlay-opacity': 0,
|
||||
'target-arrow-color': lineColor,
|
||||
|
@ -103,13 +94,29 @@ const style: cytoscape.Stylesheet[] = [
|
|||
'source-distance-from-node': theme.paddingSizes.xs,
|
||||
'target-distance-from-node': theme.paddingSizes.xs
|
||||
}
|
||||
},
|
||||
// @ts-ignore
|
||||
{
|
||||
selector: '.invisible',
|
||||
style: { visibility: 'hidden' }
|
||||
},
|
||||
{
|
||||
selector: 'edge.nodeHover',
|
||||
style: {
|
||||
width: 4
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node.hover',
|
||||
style: {
|
||||
'border-width': 4
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const cytoscapeOptions: cytoscape.CytoscapeOptions = {
|
||||
autoungrabify: true,
|
||||
boxSelectionEnabled: false,
|
||||
layout,
|
||||
maxZoom: 3,
|
||||
minZoom: 0.2,
|
||||
style
|
||||
|
|
|
@ -32,7 +32,7 @@ import { Cytoscape } from './Cytoscape';
|
|||
import { getCytoscapeElements } from './get_cytoscape_elements';
|
||||
import { PlatinumLicensePrompt } from './PlatinumLicensePrompt';
|
||||
import { Popover } from './Popover';
|
||||
import { useRefHeight } from './useRefHeight';
|
||||
import { useRefDimensions } from './useRefDimensions';
|
||||
|
||||
interface ServiceMapProps {
|
||||
serviceName?: string;
|
||||
|
@ -196,7 +196,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elements]);
|
||||
|
||||
const [wrapperRef, height] = useRefHeight();
|
||||
const { ref: wrapperRef, width, height } = useRefDimensions();
|
||||
|
||||
if (!license) {
|
||||
return null;
|
||||
|
@ -211,6 +211,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
|
|||
elements={renderedElements.current}
|
||||
serviceName={serviceName}
|
||||
height={height}
|
||||
width={width}
|
||||
style={cytoscapeDivStyle}
|
||||
>
|
||||
<Controls />
|
||||
|
|
|
@ -3,18 +3,19 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { MutableRefObject, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
|
||||
export function useRefHeight(): [
|
||||
MutableRefObject<HTMLDivElement | null>,
|
||||
number
|
||||
] {
|
||||
export function useRefDimensions() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const windowHeight = useWindowSize().height;
|
||||
const topOffset = ref.current?.getBoundingClientRect()?.top ?? 0;
|
||||
|
||||
const height = ref.current ? windowHeight - topOffset : 0;
|
||||
if (!ref.current) {
|
||||
return { ref, width: 0, height: 0 };
|
||||
}
|
||||
|
||||
return [ref, height];
|
||||
const { top, width } = ref.current.getBoundingClientRect();
|
||||
const height = windowHeight - top;
|
||||
|
||||
return { ref, width, height };
|
||||
}
|
|
@ -222,7 +222,6 @@
|
|||
"copy-to-clipboard": "^3.0.8",
|
||||
"cronstrue": "^1.51.0",
|
||||
"cytoscape": "^3.10.0",
|
||||
"cytoscape-dagre": "^2.2.2",
|
||||
"d3": "3.5.17",
|
||||
"d3-scale": "1.0.7",
|
||||
"dedent": "^0.7.0",
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
declare module 'cytoscape-dagre';
|
22
yarn.lock
22
yarn.lock
|
@ -10695,13 +10695,6 @@ cypress@^4.0.2:
|
|||
url "0.11.0"
|
||||
yauzl "2.10.0"
|
||||
|
||||
cytoscape-dagre@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/cytoscape-dagre/-/cytoscape-dagre-2.2.2.tgz#5f32a85c0ba835f167efee531df9e89ac58ff411"
|
||||
integrity sha512-zsg36qNwua/L2stJSWkcbSDcvW3E6VZf6KRe6aLnQJxuXuz89tMqI5EVYVKEcNBgzTEzFMFv0PE3T0nD4m6VDw==
|
||||
dependencies:
|
||||
dagre "^0.8.2"
|
||||
|
||||
cytoscape@^3.10.0:
|
||||
version "3.10.0"
|
||||
resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.10.0.tgz#3b462e0d35121ecd2d2702f470915fd6dae01777"
|
||||
|
@ -10967,14 +10960,6 @@ d@1:
|
|||
dependencies:
|
||||
es5-ext "^0.10.9"
|
||||
|
||||
dagre@^0.8.2:
|
||||
version "0.8.4"
|
||||
resolved "https://registry.yarnpkg.com/dagre/-/dagre-0.8.4.tgz#26b9fb8f7bdc60c6110a0458c375261836786061"
|
||||
integrity sha512-Dj0csFDrWYKdavwROb9FccHfTC4fJbyF/oJdL9LNZJ8WUvl968P6PAKEriGqfbdArVJEmmfA+UyumgWEwcHU6A==
|
||||
dependencies:
|
||||
graphlib "^2.1.7"
|
||||
lodash "^4.17.4"
|
||||
|
||||
damerau-levenshtein@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514"
|
||||
|
@ -15304,13 +15289,6 @@ graceful-fs@~1.1:
|
|||
resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
|
||||
integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=
|
||||
|
||||
graphlib@^2.1.7:
|
||||
version "2.1.7"
|
||||
resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.7.tgz#b6a69f9f44bd9de3963ce6804a2fc9e73d86aecc"
|
||||
integrity sha512-TyI9jIy2J4j0qgPmOOrHTCtpPqJGN/aurBwc6ZT+bRii+di1I+Wv3obRhVrmBEXet+qkMaEX67dXrwsd3QQM6w==
|
||||
dependencies:
|
||||
lodash "^4.17.5"
|
||||
|
||||
graphql-anywhere@^4.1.0-alpha.0:
|
||||
version "4.1.16"
|
||||
resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-4.1.16.tgz#82bb59643e30183cfb7b485ed4262a7b39d8a6c1"
|
||||
|
|
Loading…
Reference in a new issue