Commit ff6b8c5a by Ryan McKinley Committed by Dominik Prokop

DataLinks: enable data links in Gauge, BarGauge and SingleStat2 panel (#18605)

* datalink on field

* add dataFrame to view

* Use scoped variables to pass series name and value time to data links interpolation

* Use scoped variables to pass series name and value time to data links interpolation

* Enable value specific variable suggestions when Gauge is displaying values

* Fix prettier

* Add basic context menu with data links to GaugePanel

* Fix incorrect import in grafana/ui

* Add custom cursor indicating datalinks available via context menu (in Gauge only now)

* Add data links to SingleStat2

* Minor refactor

* Retrieve data links in a lazy way

* Update test to respect links retrieval being lazy

* delay link creation

* cleanup

* Add origin to LinkModel and introduce field & panel links suppliers

* Add value time and series name field link supplier

* Remove links prop from visualization and implement common UI for data links context menu

* Update snapshot

* Rename className prop to clickTargetClassName

* Simplify condition

* Updated drilldown dashboard and minor changes

* Use class name an onClick handler on the top level dom element in visualization

* Enable series name interpolation when presented value is a calculation
parent e1924608
......@@ -15,14 +15,15 @@
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"iteration": 1565097360786,
"id": 13844,
"iteration": 1566896059256,
"links": [],
"panels": [
{
"content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod",
"gridPos": {
"h": 6,
"w": 14,
"h": 9,
"w": 12,
"x": 0,
"y": 0
},
......@@ -55,9 +56,9 @@
"thresholdMarkers": true
},
"gridPos": {
"h": 6,
"w": 10,
"x": 14,
"h": 9,
"w": 4,
"x": 12,
"y": 0
},
"id": 6,
......@@ -117,6 +118,117 @@
"valueName": "avg"
},
{
"cacheTimeout": null,
"gridPos": {
"h": 9,
"w": 4,
"x": 16,
"y": 0
},
"id": 8,
"links": [],
"options": {
"fieldOptions": {
"calcs": ["mean"],
"defaults": {
"links": [
{
"targetBlank": true,
"title": "Go to drilldown",
"url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1&${__all_variables}&${__url_time_range}"
}
],
"mappings": [],
"max": 100,
"min": 0,
"nullValueMode": "connected",
"thresholds": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
],
"unit": "none"
},
"override": {},
"values": false
},
"orientation": "horizontal",
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"pluginVersion": "6.4.0-pre",
"targets": [
{
"refId": "A",
"scenarioId": "random_walk"
}
],
"timeFrom": null,
"timeShift": null,
"title": "React gauge datalink",
"type": "gauge"
},
{
"cacheTimeout": null,
"gridPos": {
"h": 9,
"w": 4,
"x": 20,
"y": 0
},
"id": 9,
"links": [],
"options": {
"displayMode": "basic",
"fieldOptions": {
"calcs": ["mean"],
"defaults": {
"links": [
{
"targetBlank": true,
"title": "Go to drilldown",
"url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1&${__all_variables}&${__url_time_range}"
}
],
"mappings": [],
"max": 100,
"min": 0,
"nullValueMode": "connected",
"thresholds": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
],
"unit": "none"
},
"override": {},
"values": false
},
"orientation": "vertical"
},
"pluginVersion": "6.4.0-pre",
"targets": [
{
"refId": "A",
"scenarioId": "random_walk"
}
],
"timeFrom": null,
"timeShift": null,
"title": "React gauge datalink",
"type": "bargauge"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
......@@ -128,7 +240,7 @@
"h": 13,
"w": 24,
"x": 0,
"y": 6
"y": 9
},
"id": 2,
"legend": {
......@@ -296,5 +408,5 @@
"timezone": "",
"title": "Templating - Nested Template Variables",
"uid": "-Y-tnEDWk",
"version": 11
"version": 2
}
......@@ -3,6 +3,7 @@ import { ValueMapping } from './valueMapping';
import { QueryResultBase, Labels, NullValueMode } from './data';
import { FieldCalcs } from '../utils/index';
import { DisplayProcessor } from './displayValue';
import { DataLink } from './dataLink';
export enum FieldType {
time = 'time', // or date
......@@ -36,6 +37,9 @@ export interface FieldConfig {
// Used when reducing field values
nullValueMode?: NullValueMode;
// The behavior when clicking on a result
links?: DataLink[];
// Alternative to empty string
noValue?: string;
}
......
/**
* Link configuration. The values may contain variables that need to be
* processed before running
*/
export interface DataLink {
url: string;
title: string;
targetBlank?: boolean;
}
export type LinkTarget = '_blank' | '_self';
/**
* Processed Link Model. The values are ready to use
*/
export interface LinkModel<T> {
href: string;
title: string;
target: LinkTarget;
origin: T;
}
/**
* Provides a way to produce links on demand
*
* TODO: ScopedVars in in GrafanaUI package!
*/
export interface LinkModelSupplier<T extends object> {
getLinks(scopedVars?: any): Array<LinkModel<T>>;
}
......@@ -44,6 +44,10 @@ export class DataFrameView<T = any> implements Vector<T> {
this.obj = obj;
}
get dataFrame() {
return this.data;
}
get length() {
return this.data.length;
}
......
......@@ -26,6 +26,8 @@ export interface Props extends Themeable {
orientation: VizOrientation;
itemSpacing?: number;
displayMode: 'basic' | 'lcd' | 'gradient';
onClick?: React.MouseEventHandler<HTMLElement>;
className?: string;
}
export class BarGauge extends PureComponent<Props> {
......@@ -43,16 +45,20 @@ export class BarGauge extends PureComponent<Props> {
};
render() {
const { onClick, className } = this.props;
const { title } = this.props.value;
const styles = getTitleStyles(this.props);
if (!title) {
return this.renderBarAndValue();
return (
<div style={styles.wrapper} onClick={onClick} className={className}>
{this.renderBarAndValue()}
</div>
);
}
const styles = getTitleStyles(this.props);
return (
<div style={styles.wrapper}>
<div style={styles.wrapper} onClick={onClick} className={className}>
<div style={styles.title}>{title}</div>
{this.renderBarAndValue()}
</div>
......
......@@ -4,41 +4,51 @@ exports[`BarGauge Render with basic options should render 1`] = `
<div
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "row-reverse",
"justifyContent": "flex-end",
"flexDirection": "column",
"overflow": "hidden",
}
}
>
<div
className="bar-gauge__value"
style={
Object {
"alignItems": "center",
"color": "#73BF69",
"display": "flex",
"fontSize": "27.2727px",
"height": "300px",
"lineHeight": 1,
"paddingLeft": "10px",
"width": "60px",
"flexDirection": "row-reverse",
"justifyContent": "flex-end",
}
}
>
25
</div>
<div
style={
Object {
"background": "rgba(115, 191, 105, 0.25)",
"borderRadius": "3px",
"borderRight": "2px solid #73BF69",
"height": "300px",
"transition": "width 1s",
"width": "60px",
<div
className="bar-gauge__value"
style={
Object {
"alignItems": "center",
"color": "#73BF69",
"display": "flex",
"fontSize": "27.2727px",
"height": "300px",
"lineHeight": 1,
"paddingLeft": "10px",
"width": "60px",
}
}
}
/>
>
25
</div>
<div
style={
Object {
"background": "rgba(115, 191, 105, 0.25)",
"borderRadius": "3px",
"borderRight": "2px solid #73BF69",
"height": "300px",
"transition": "width 1s",
"width": "60px",
}
}
/>
</div>
</div>
`;
// Library
import React, { PureComponent, ReactNode, CSSProperties } from 'react';
import $ from 'jquery';
import { css } from 'emotion';
import { css, cx } from 'emotion';
import { DisplayValue } from '@grafana/data';
// Utils
......@@ -27,6 +27,8 @@ export interface Props extends Themeable {
suffix?: DisplayValue;
sparkline?: BigValueSparkline;
backgroundColor?: string;
onClick?: React.MouseEventHandler<HTMLElement>;
className?: string;
}
/*
......@@ -119,15 +121,19 @@ export class BigValue extends PureComponent<Props> {
}
render() {
const { height, width, value, prefix, suffix, sparkline, backgroundColor } = this.props;
const { height, width, value, prefix, suffix, sparkline, backgroundColor, onClick, className } = this.props;
return (
<div
className={css({
position: 'relative',
display: 'table',
})}
className={cx(
css({
position: 'relative',
display: 'table',
}),
className
)}
style={{ width, height, backgroundColor }}
onClick={onClick}
>
{value.title && (
<div
......@@ -143,6 +149,7 @@ export class BigValue extends PureComponent<Props> {
{value.title}
</div>
)}
<span
className={css({
lineHeight: 1,
......
......@@ -3,10 +3,11 @@ import { css, cx } from 'emotion';
import useClickAway from 'react-use/lib/useClickAway';
import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index';
import { Portal, List } from '../index';
import { LinkTarget } from '@grafana/data';
export interface ContextMenuItem {
label: string;
target?: string;
target?: LinkTarget;
icon?: string;
url?: string;
onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;
......
import React, { useState } from 'react';
import { ContextMenu, ContextMenuGroup } from '../ContextMenu/ContextMenu';
interface WithContextMenuProps {
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
getContextMenuItems: () => ContextMenuGroup[];
}
export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getContextMenuItems }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [menuPosition, setMenuPositon] = useState({ x: 0, y: 0 });
return (
<>
{children({
openMenu: e => {
setIsMenuOpen(true);
setMenuPositon({
x: e.pageX,
y: e.pageY,
});
},
})}
{isMenuOpen && (
<ContextMenu
onClose={() => setIsMenuOpen(false)}
x={menuPosition.x}
y={menuPosition.y}
items={getContextMenuItems()}
/>
)}
</>
);
};
......@@ -59,6 +59,7 @@ export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
onBlur={onTitleBlur}
inputWidth={15}
labelWidth={5}
placeholder="Show details"
/>
<FormField
......
import React from 'react';
import { WithContextMenu } from '../ContextMenu/WithContextMenu';
import { LinkModelSupplier } from '@grafana/data';
import { linkModelToContextMenuItems } from '../../utils/dataLinks';
import { css } from 'emotion';
interface DataLinksContextMenuProps {
children: (props: { openMenu?: React.MouseEventHandler<HTMLElement>; targetClassName?: string }) => JSX.Element;
links?: LinkModelSupplier<any>;
}
export const DataLinksContextMenu: React.FC<DataLinksContextMenuProps> = ({ children, links }) => {
if (!links) {
return children({});
}
const getDataLinksContextMenuItems = () => {
return [{ items: linkModelToContextMenuItems(links), label: 'Data links' }];
};
// Use this class name (exposed via render prop) to add context menu indicator to the click target of the visualization
const targetClassName = css`
cursor: context-menu;
`;
return (
<WithContextMenu getContextMenuItems={getDataLinksContextMenuItems}>
{({ openMenu }) => {
return children({ openMenu, targetClassName });
}}
</WithContextMenu>
);
};
......@@ -68,7 +68,7 @@ export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(({ value, on
{(!value || (value && value.length < (maxLinks || Infinity))) && (
<Button variant="inverse" icon="fa fa-plus" onClick={() => onAdd()}>
Create link
Add link
</Button>
)}
</>
......
......@@ -2,7 +2,7 @@
margin-bottom: $space-xxs;
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
text-align: left;
position: relative;
......
......@@ -15,6 +15,8 @@ export interface Props extends Themeable {
showThresholdLabels: boolean;
width: number;
value: DisplayValue;
onClick?: React.MouseEventHandler<HTMLElement>;
className?: string;
}
const FONT_SCALE = 1;
......@@ -133,24 +135,16 @@ export class Gauge extends PureComponent<Props> {
}
}
render() {
const { width, value, height } = this.props;
renderVisualization = () => {
const { width, value, height, onClick } = this.props;
const autoProps = calculateGaugeAutoProps(width, height, value.title);
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<>
<div
style={{ height: `${autoProps.gaugeHeight}px`, width: '100%' }}
ref={element => (this.canvasElement = element)}
onClick={onClick}
/>
{autoProps.showLabel && (
<div
......@@ -163,11 +157,30 @@ export class Gauge extends PureComponent<Props> {
position: 'relative',
width: '100%',
top: '-4px',
cursor: 'default',
}}
>
{value.title}
</div>
)}
</>
);
};
render() {
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
overflow: 'hidden',
}}
className={this.props.className}
>
{this.renderVisualization()}
</div>
);
}
......
......@@ -59,7 +59,7 @@ describe('Next id to add', () => {
it('should be 4', () => {
const { instance } = setup();
instance.addMapping();
instance.onAddMapping();
expect(instance.state.nextIdToAdd).toEqual(4);
});
......
......@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
import MappingRow from './MappingRow';
import { MappingType, ValueMapping } from '@grafana/data';
import { Button } from '../index';
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
export interface Props {
......@@ -30,7 +31,7 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
}
addMapping = () =>
onAddMapping = () =>
this.setState(prevState => ({
valueMappings: [
...prevState.valueMappings,
......@@ -81,16 +82,21 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
const { valueMappings } = this.state;
return (
<PanelOptionsGroup title="Add value mapping" onAdd={this.addMapping}>
{valueMappings.length > 0 &&
valueMappings.map((valueMapping, index) => (
<MappingRow
key={`${valueMapping.text}-${index}`}
valueMapping={valueMapping}
updateValueMapping={this.updateGauge}
removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
/>
))}
<PanelOptionsGroup title="Value mappings">
<div>
{valueMappings.length > 0 &&
valueMappings.map((valueMapping, index) => (
<MappingRow
key={`${valueMapping.text}-${index}`}
valueMapping={valueMapping}
updateValueMapping={this.updateGauge}
removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
/>
))}
<Button variant="inverse" icon="fa fa-plus" onClick={this.onAddMapping}>
Add mapping
</Button>
</div>
</PanelOptionsGroup>
);
}
......
......@@ -2,37 +2,45 @@
exports[`Render should render component 1`] = `
<Component
onAdd={[Function]}
title="Add value mapping"
title="Value mappings"
>
<MappingRow
key="Ok-0"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"id": 1,
"operator": "",
"text": "Ok",
"type": 1,
"value": "20",
<div>
<MappingRow
key="Ok-0"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"id": 1,
"operator": "",
"text": "Ok",
"type": 1,
"value": "20",
}
}
}
/>
<MappingRow
key="Meh-1"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"from": "21",
"id": 2,
"operator": "",
"text": "Meh",
"to": "30",
"type": 2,
/>
<MappingRow
key="Meh-1"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"from": "21",
"id": 2,
"operator": "",
"text": "Meh",
"to": "30",
"type": 2,
}
}
}
/>
/>
<Button
icon="fa fa-plus"
onClick={[Function]}
variant="inverse"
>
Add mapping
</Button>
</div>
</Component>
`;
......@@ -76,4 +76,5 @@ export { CallToActionCard } from './CallToActionCard/CallToActionCard';
export { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu';
export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggestions';
export { DataLinksEditor } from './DataLinks/DataLinksEditor';
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
export { SeriesIcon } from './Legend/SeriesIcon';
import { ContextMenuItem } from '../components/ContextMenu/ContextMenu';
import { LinkModelSupplier } from '@grafana/data';
export const DataLinkBuiltInVars = {
keepTime: '__url_time_range',
includeVars: '__all_variables',
seriesName: '__series_name',
valueTime: '__value_time',
};
/**
* Delays creating links until we need to open the ContextMenu
*/
export const linkModelToContextMenuItems: (links: LinkModelSupplier<any>) => ContextMenuItem[] = links => {
return links.getLinks().map(link => {
return {
label: link.title,
// TODO: rename to href
url: link.href,
target: link.target,
icon: `fa ${link.target === '_self' ? 'fa-link' : 'fa-external-link'}`,
};
});
};
......@@ -6,6 +6,7 @@ import {
FieldConfig,
DisplayValue,
GraphSeriesValue,
DataFrameView,
} from '@grafana/data';
import toNumber from 'lodash/toNumber';
......@@ -14,6 +15,7 @@ import toString from 'lodash/toString';
import { GrafanaTheme, InterpolateFunction, ScopedVars } from '../types/index';
import { getDisplayProcessor } from './displayProcessor';
import { getFlotPairs } from './flotPairs';
import { DataLinkBuiltInVars } from '../utils/dataLinks';
export interface FieldDisplayOptions {
values?: boolean; // If true show each row value
......@@ -23,7 +25,7 @@ export interface FieldDisplayOptions {
defaults: FieldConfig; // Use these values unless otherwise stated
override: FieldConfig; // Set these values regardless of the source
}
// TODO: use built in variables, same as for data links?
export const VAR_SERIES_NAME = '__series_name';
export const VAR_FIELD_NAME = '__field_name';
export const VAR_CALC = '__calc';
......@@ -59,10 +61,15 @@ function getTitleTemplate(title: string | undefined, stats: string[], data?: Dat
}
export interface FieldDisplay {
name: string; // NOT title!
name: string; // The field name (title is in display)
field: FieldConfig;
display: DisplayValue;
sparkline?: GraphSeriesValue[][];
// Expose to the original values for delayed inspection (DataLinks etc)
view?: DataFrameView;
column?: number; // The field column index
row?: number; // only filled in when the value is from a row (ie, not a reduction)
}
export interface GetFieldDisplayValuesOptions {
......@@ -75,8 +82,19 @@ export interface GetFieldDisplayValuesOptions {
export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25;
const getTimeColumnIdx = (series: DataFrame) => {
let timeColumn = -1;
for (let i = 0; i < series.fields.length; i++) {
if (series.fields[i].type === FieldType.time) {
timeColumn = i;
break;
}
}
return timeColumn;
};
export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => {
const { data, replaceVariables, fieldOptions, sparkline } = options;
const { data, replaceVariables, fieldOptions } = options;
const { defaults, override } = fieldOptions;
const calcs = fieldOptions.calcs.length ? fieldOptions.calcs : [ReducerID.last];
......@@ -96,17 +114,11 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
name: series.refId ? series.refId : `Series[${s}]`,
};
}
scopedVars[VAR_SERIES_NAME] = { text: 'Series', value: series.name };
let timeColumn = -1;
if (sparkline) {
for (let i = 0; i < series.fields.length; i++) {
if (series.fields[i].type === FieldType.time) {
timeColumn = i;
break;
}
}
}
scopedVars[DataLinkBuiltInVars.seriesName] = { text: 'Series', value: series.name };
const timeColumn = getTimeColumnIdx(series);
const view = new DataFrameView(series);
for (let i = 0; i < series.fields.length && !hitLimit; i++) {
const field = series.fields[i];
......@@ -131,7 +143,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
const title = config.title ? config.title : defaultTitle;
// Show all number fields
// Show all rows
if (fieldOptions.values) {
const usesCellValues = title.indexOf(VAR_CELL_PREFIX) >= 0;
......@@ -154,6 +166,9 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
name,
field: config,
display: displayValue,
view,
column: i,
row: j,
});
if (values.length >= limit) {
......@@ -166,15 +181,15 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
field,
reducers: calcs, // The stats to calculate
});
let sparkline: GraphSeriesValue[][] | undefined = undefined;
// Single sparkline for a field
const points =
timeColumn < 0
? undefined
: getFlotPairs({
xField: series.fields[timeColumn],
yField: series.fields[i],
});
// Single sparkline for every reducer
if (options.sparkline && timeColumn >= 0) {
sparkline = getFlotPairs({
xField: series.fields[timeColumn],
yField: series.fields[i],
});
}
for (const calc of calcs) {
scopedVars[VAR_CALC] = { value: calc, text: calc };
......@@ -184,7 +199,9 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
name,
field: config,
display: displayValue,
sparkline: points,
sparkline,
view,
column: i,
});
}
}
......
......@@ -6,6 +6,7 @@ export * from './fieldDisplay';
export * from './validate';
export { getFlotPairs } from './flotPairs';
export * from './slate';
export * from './dataLinks';
export { default as ansicolor } from './ansicolor';
// Export with a namespace
......
......@@ -11,6 +11,7 @@ import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { ClickOutsideWrapper } from '@grafana/ui';
import { DataLink } from '@grafana/data';
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
export interface Props {
panel: PanelModel;
......@@ -88,7 +89,7 @@ export class PanelHeader extends Component<Props, State> {
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
links={getPanelLinksSupplier(panel)}
error={error}
/>
<div
......
......@@ -6,14 +6,7 @@ import { PanelModel } from '../../state';
describe('Render', () => {
it('should render component', () => {
const panel = new PanelModel({});
const links: any[] = [
{
url: 'asd',
title: 'asd',
},
];
const wrapper = shallow(<PanelHeaderCorner panel={panel} links={links} />);
const wrapper = shallow(<PanelHeaderCorner panel={panel} />);
const instance = wrapper.instance() as PanelHeaderCorner;
expect(instance.getInfoContent()).toBeDefined();
......
import React, { Component } from 'react';
import { renderMarkdown } from '@grafana/data';
import { renderMarkdown, LinkModelSupplier } from '@grafana/data';
import { Tooltip, ScopedVars, PopoverContent } from '@grafana/ui';
import { DataLink } from '@grafana/data';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import templateSrv from 'app/features/templating/template_srv';
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
enum InfoMode {
......@@ -20,7 +18,7 @@ interface Props {
title?: string;
description?: string;
scopedVars?: ScopedVars;
links?: DataLink[];
links?: LinkModelSupplier<PanelModel>;
error?: string;
}
......@@ -45,22 +43,21 @@ export class PanelHeaderCorner extends Component<Props> {
getInfoContent = (): JSX.Element => {
const { panel } = this.props;
const markdown = panel.description || '';
const linkSrv = new LinkSrv(templateSrv, this.timeSrv);
const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars);
const markedInterpolatedMarkdown = renderMarkdown(interpolatedMarkdown);
const links = this.props.links && this.props.links.getLinks(panel);
return (
<div className="panel-info-content markdown-html">
<div dangerouslySetInnerHTML={{ __html: markedInterpolatedMarkdown }} />
{panel.links && panel.links.length > 0 && (
{links && links.length > 0 && (
<ul className="panel-info-corner-links">
{panel.links.map((link, idx) => {
const info = linkSrv.getDataLinkUIModel(link, panel.scopedVars);
{links.map((link, idx) => {
return (
<li key={idx}>
<a className="panel-info-corner-links__item" href={info.href} target={info.target}>
{info.title}
<a className="panel-info-corner-links__item" href={link.href} target={link.target}>
{link.title}
</a>
</li>
);
......
......@@ -3,7 +3,7 @@ import { DashboardModel } from '../state/DashboardModel';
import { PanelModel } from '../state/PanelModel';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
import { expect } from 'test/lib/common';
import { DataLinkBuiltInVars } from 'app/features/panel/panellinks/link_srv';
import { DataLinkBuiltInVars } from '@grafana/ui';
jest.mock('app/core/services/context_srv', () => ({}));
......
......@@ -20,7 +20,7 @@ import {
MIN_PANEL_HEIGHT,
DEFAULT_PANEL_SPAN,
} from 'app/core/constants';
import { DataLinkBuiltInVars } from 'app/features/panel/panellinks/link_srv';
import { DataLinkBuiltInVars } from '@grafana/ui';
export class DashboardMigrator {
dashboard: DashboardModel;
......
......@@ -18,8 +18,8 @@ import {
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { auto } from 'angular';
import { TemplateSrv } from '../templating/template_srv';
import { LinkSrv } from './panellinks/link_srv';
import { PanelPluginMeta } from '@grafana/ui/src/types/panel';
import { getPanelLinksSupplier } from './panellinks/linkSuppliers';
export class PanelCtrl {
panel: any;
......@@ -255,31 +255,31 @@ export class PanelCtrl {
markdown = this.error || this.panel.description || '';
}
const linkSrv: LinkSrv = this.$injector.get('linkSrv');
const templateSrv: TemplateSrv = this.$injector.get('templateSrv');
const interpolatedMarkdown = templateSrv.replace(markdown, this.panel.scopedVars);
let html = '<div class="markdown-html panel-info-content">';
const md = renderMarkdown(interpolatedMarkdown);
html += config.disableSanitizeHtml ? md : sanitize(md);
const links = this.panel.links && getPanelLinksSupplier(this.panel).getLinks();
if (this.panel.links && this.panel.links.length > 0) {
if (links && links.length > 0) {
html += '<ul class="panel-info-corner-links">';
for (const link of this.panel.links) {
const info = linkSrv.getDataLinkUIModel(link, this.panel.scopedVars);
for (const link of links) {
html +=
'<li><a class="panel-menu-link" href="' +
escapeHtml(info.href) +
escapeHtml(link.href) +
'" target="' +
escapeHtml(info.target) +
escapeHtml(link.target) +
'">' +
escapeHtml(info.title) +
escapeHtml(link.title) +
'</a></li>';
}
html += '</ul>';
}
html += '</div>';
return html;
}
......
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { FieldDisplay, ScopedVars, DataLinkBuiltInVars } from '@grafana/ui';
import { LinkModelSupplier, DataFrameHelper, FieldType } from '@grafana/data';
import { getLinkSrv } from './link_srv';
/**
* Link suppliers creates link models based on a link origin
*/
export const getFieldLinksSupplier = (value: FieldDisplay): LinkModelSupplier<FieldDisplay> | undefined => {
const links = value.field.links;
if (!links || links.length === 0) {
return undefined;
}
return {
getLinks: (_scopedVars?: any) => {
const scopedVars: ScopedVars = {};
// TODO, add values to scopedVars and/or pass objects to event listeners
if (value.view) {
scopedVars[DataLinkBuiltInVars.seriesName] = {
text: 'Series',
value: value.view.dataFrame.name,
};
const field = value.column ? value.view.dataFrame.fields[value.column] : undefined;
if (field) {
console.log('Full Field Info:', field);
}
if (value.row) {
const row = value.view.get(value.row);
console.log('ROW:', row);
const dataFrame = new DataFrameHelper(value.view.dataFrame);
const timeField = dataFrame.getFirstFieldOfType(FieldType.time);
if (timeField) {
scopedVars[DataLinkBuiltInVars.valueTime] = {
text: 'Value time',
value: timeField.values.get(value.row),
};
}
}
} else {
console.log('VALUE', value);
}
return links.map(link => {
return getLinkSrv().getDataLinkUIModel(link, scopedVars, value);
});
},
};
};
export const getPanelLinksSupplier = (value: PanelModel): LinkModelSupplier<PanelModel> => {
const links = value.links;
if (!links || links.length === 0) {
return undefined;
}
return {
getLinks: () => {
return links.map(link => {
return getLinkSrv().getDataLinkUIModel(link, value.scopedVars, value);
});
},
};
};
......@@ -3,15 +3,8 @@ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import templateSrv, { TemplateSrv } from 'app/features/templating/template_srv';
import coreModule from 'app/core/core_module';
import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url';
import { VariableSuggestion, ScopedVars, VariableOrigin } from '@grafana/ui';
import { TimeSeriesValue, DateTime, dateTime, DataLink, KeyValue, deprecationWarning } from '@grafana/data';
export const DataLinkBuiltInVars = {
keepTime: '__url_time_range',
includeVars: '__all_variables',
seriesName: '__series_name',
valueTime: '__value_time',
};
import { VariableSuggestion, ScopedVars, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui';
import { DataLink, KeyValue, deprecationWarning, LinkModel } from '@grafana/data';
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
...templateSrv.variables.map(variable => ({
......@@ -44,22 +37,17 @@ export const getDataLinksVariableSuggestions = (): VariableSuggestion[] => [
},
];
type LinkTarget = '_blank' | '_self';
export interface LinkModel {
href: string;
title: string;
target: LinkTarget;
}
export const getCalculationValueDataLinksVariableSuggestions = (): VariableSuggestion[] => [
...getPanelLinksVariableSuggestions(),
{
value: `${DataLinkBuiltInVars.seriesName}`,
documentation: 'Adds series name',
origin: VariableOrigin.BuiltIn,
},
];
interface LinkDataPoint {
datapoint: TimeSeriesValue[];
seriesName: string;
[key: number]: any;
}
export interface LinkService {
getDataLinkUIModel: (link: DataLink, scopedVars: ScopedVars, dataPoint?: LinkDataPoint) => LinkModel;
getDataPointVars: (seriesName: string, dataPointTs: DateTime) => ScopedVars;
getDataLinkUIModel: <T>(link: DataLink, scopedVars: ScopedVars, origin: T) => LinkModel<T>;
}
export class LinkSrv implements LinkService {
......@@ -90,33 +78,20 @@ export class LinkSrv implements LinkService {
return info;
}
getDataPointVars = (seriesName: string, valueTime: DateTime) => {
return {
[DataLinkBuiltInVars.valueTime]: {
text: valueTime.valueOf(),
value: valueTime.valueOf(),
},
[DataLinkBuiltInVars.seriesName]: {
text: seriesName,
value: seriesName,
},
};
};
getDataLinkUIModel = (link: DataLink, scopedVars: ScopedVars, dataPoint?: LinkDataPoint) => {
getDataLinkUIModel = <T>(link: DataLink, scopedVars: ScopedVars, origin: T) => {
const params: KeyValue = {};
const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl());
const info: LinkModel = {
const info: LinkModel<T> = {
href: link.url,
title: this.templateSrv.replace(link.title || '', scopedVars),
target: link.targetBlank ? '_blank' : '_self',
origin,
};
this.templateSrv.fillVariableValuesForUrl(params, scopedVars);
const variablesQuery = toUrlParams(params);
info.href = this.templateSrv.replace(link.url, {
...scopedVars,
[DataLinkBuiltInVars.keepTime]: {
......@@ -129,13 +104,6 @@ export class LinkSrv implements LinkService {
},
});
if (dataPoint) {
info.href = this.templateSrv.replace(
info.href,
this.getDataPointVars(dataPoint.seriesName, dateTime(dataPoint.datapoint[0]))
);
}
return info;
};
......@@ -146,7 +114,7 @@ export class LinkSrv implements LinkService {
*/
getPanelLinkAnchorInfo(link: DataLink, scopedVars: ScopedVars) {
deprecationWarning('link_srv.ts', 'getPanelLinkAnchorInfo', 'getDataLinkUIModel');
return this.getDataLinkUIModel(link, scopedVars);
return this.getDataLinkUIModel(link, scopedVars, {});
}
}
......
import { LinkSrv, DataLinkBuiltInVars } from '../link_srv';
import { LinkSrv } from '../link_srv';
import { DataLinkBuiltInVars } from '@grafana/ui';
import _ from 'lodash';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { TemplateSrv } from 'app/features/templating/template_srv';
......@@ -80,6 +81,7 @@ describe('linkSrv', () => {
title: 'Any title',
url: `/d/1?$${DataLinkBuiltInVars.keepTime}`,
},
{},
{}
).href
).toEqual('/d/1?from=now-1h&to=now');
......@@ -92,32 +94,43 @@ describe('linkSrv', () => {
title: 'Any title',
url: `/d/1?$${DataLinkBuiltInVars.includeVars}`,
},
{},
{}
).href
).toEqual('/d/1?var-test1=val1&var-test2=val2');
});
it('should interpolate series name from datapoint', () => {
it('should interpolate series name', () => {
expect(
linkSrv.getDataLinkUIModel(
{
title: 'Any title',
url: `/d/1?var-test=$${DataLinkBuiltInVars.seriesName}`,
},
{},
dataPointMock
{
[DataLinkBuiltInVars.seriesName]: {
value: 'A-series',
text: 'A-series',
},
},
{}
).href
).toEqual('/d/1?var-test=A-series');
});
it('should interpolate time range based on datapoint timestamp', () => {
it('should interpolate value time', () => {
expect(
linkSrv.getDataLinkUIModel(
{
title: 'Any title',
url: `/d/1?time=$${DataLinkBuiltInVars.valueTime}`,
},
{},
dataPointMock
{
[DataLinkBuiltInVars.valueTime]: {
value: dataPointMock.datapoint[0],
text: dataPointMock.datapoint[0],
},
},
{}
).href
).toEqual('/d/1?time=1000000001');
});
......
......@@ -5,11 +5,12 @@ import React, { PureComponent } from 'react';
import { config } from 'app/core/config';
// Components
import { BarGauge, VizRepeater, getFieldDisplayValues, FieldDisplay } from '@grafana/ui';
import { BarGauge, VizRepeater, getFieldDisplayValues, FieldDisplay, DataLinksContextMenu } from '@grafana/ui';
// Types
import { BarGaugeOptions } from './types';
import { PanelProps } from '@grafana/ui';
import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
......@@ -17,18 +18,26 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
const { field, display } = value;
return (
<BarGauge
value={display}
width={width}
height={height}
orientation={options.orientation}
thresholds={field.thresholds}
theme={config.theme}
itemSpacing={this.getItemSpacing()}
displayMode={options.displayMode}
minValue={field.min}
maxValue={field.max}
/>
<DataLinksContextMenu links={getFieldLinksSupplier(value)}>
{({ openMenu, targetClassName }) => {
return (
<BarGauge
value={display}
width={width}
height={height}
orientation={options.orientation}
thresholds={field.thresholds}
theme={config.theme}
itemSpacing={this.getItemSpacing()}
displayMode={options.displayMode}
minValue={field.min}
maxValue={field.max}
onClick={openMenu}
className={targetClassName}
/>
);
}}
</DataLinksContextMenu>
);
};
......
......@@ -12,11 +12,16 @@ import {
FormLabel,
PanelEditorProps,
Select,
DataLinksEditor,
} from '@grafana/ui';
import { FieldConfig } from '@grafana/data';
import { FieldConfig, DataLink } from '@grafana/data';
import { Threshold, ValueMapping } from '@grafana/data';
import { BarGaugeOptions, orientationOptions, displayModes } from './types';
import {
getDataLinksVariableSuggestions,
getCalculationValueDataLinksVariableSuggestions,
} from 'app/features/panel/panellinks/link_srv';
export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) => {
......@@ -51,11 +56,20 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
onOrientationChange = ({ value }: any) => this.props.onOptionsChange({ ...this.props.options, orientation: value });
onDisplayModeChange = ({ value }: any) => this.props.onOptionsChange({ ...this.props.options, displayMode: value });
onDataLinksChanged = (links: DataLink[]) => {
this.onDefaultsChange({
...this.props.options.fieldOptions.defaults,
links,
});
};
render() {
const { options } = this.props;
const { fieldOptions } = options;
const { defaults } = fieldOptions;
const suggestions = fieldOptions.values
? getDataLinksVariableSuggestions()
: getCalculationValueDataLinksVariableSuggestions();
const labelWidth = 6;
return (
......@@ -92,6 +106,15 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />
<PanelOptionsGroup title="Data links">
<DataLinksEditor
value={defaults.links}
onChange={this.onDataLinksChanged}
suggestions={suggestions}
maxLinks={10}
/>
</PanelOptionsGroup>
</>
);
}
......
......@@ -5,11 +5,12 @@ import React, { PureComponent } from 'react';
import { config } from 'app/core/config';
// Components
import { Gauge, FieldDisplay, getFieldDisplayValues, VizOrientation } from '@grafana/ui';
import { Gauge, FieldDisplay, getFieldDisplayValues, VizOrientation, DataLinksContextMenu } from '@grafana/ui';
// Types
import { GaugeOptions } from './types';
import { PanelProps, VizRepeater } from '@grafana/ui';
import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
......@@ -17,17 +18,25 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
const { field, display } = value;
return (
<Gauge
value={display}
width={width}
height={height}
thresholds={field.thresholds}
showThresholdLabels={options.showThresholdLabels}
showThresholdMarkers={options.showThresholdMarkers}
minValue={field.min}
maxValue={field.max}
theme={config.theme}
/>
<DataLinksContextMenu links={getFieldLinksSupplier(value)}>
{({ openMenu, targetClassName }) => {
return (
<Gauge
value={display}
width={width}
height={height}
thresholds={field.thresholds}
showThresholdLabels={options.showThresholdLabels}
showThresholdMarkers={options.showThresholdMarkers}
minValue={field.min}
maxValue={field.max}
theme={config.theme}
onClick={openMenu}
className={targetClassName}
/>
);
}}
</DataLinksContextMenu>
);
};
......
......@@ -10,10 +10,15 @@ import {
FieldPropertiesEditor,
Switch,
PanelOptionsGroup,
DataLinksEditor,
} from '@grafana/ui';
import { Threshold, ValueMapping, FieldConfig } from '@grafana/data';
import { Threshold, ValueMapping, FieldConfig, DataLink } from '@grafana/data';
import { GaugeOptions } from './types';
import {
getCalculationValueDataLinksVariableSuggestions,
getDataLinksVariableSuggestions,
} from 'app/features/panel/panellinks/link_srv';
export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> {
labelWidth = 6;
......@@ -56,10 +61,20 @@ export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOption
});
};
onDataLinksChanged = (links: DataLink[]) => {
this.onDefaultsChange({
...this.props.options.fieldOptions.defaults,
links,
});
};
render() {
const { options } = this.props;
const { fieldOptions, showThresholdLabels, showThresholdMarkers } = options;
const { defaults } = fieldOptions;
const suggestions = fieldOptions.values
? getDataLinksVariableSuggestions()
: getCalculationValueDataLinksVariableSuggestions();
return (
<>
......@@ -92,6 +107,15 @@ export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOption
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />
<PanelOptionsGroup title="Data links">
<DataLinksEditor
value={defaults.links}
onChange={this.onDataLinksChanged}
suggestions={suggestions}
maxLinks={10}
/>
</PanelOptionsGroup>
</>
);
}
......
......@@ -25,7 +25,7 @@ import ReactDOM from 'react-dom';
import { GraphLegendProps, Legend } from './Legend/Legend';
import { GraphCtrl } from './module';
import { getValueFormat, ContextMenuItem, ContextMenuGroup } from '@grafana/ui';
import { getValueFormat, ContextMenuItem, ContextMenuGroup, DataLinkBuiltInVars } from '@grafana/ui';
import { provideTheme } from 'app/core/utils/ConfigProvider';
import { DataLink, toUtc } from '@grafana/data';
import { GraphContextMenuCtrl, FlotDataPoint } from './GraphContextMenuCtrl';
......@@ -196,10 +196,15 @@ class GraphElement {
{
items: [
...dataLinks.map<ContextMenuItem>(link => {
const linkUiModel = this.linkSrv.getDataLinkUIModel(link, this.panel.scopedVars, {
seriesName: item.series.alias,
datapoint: item.datapoint,
});
const linkUiModel = this.linkSrv.getDataLinkUIModel(
link,
{
...this.panel.scopedVars,
[DataLinkBuiltInVars.seriesName]: { value: item.series.alias, text: item.series.alias },
[DataLinkBuiltInVars.valueTime]: { value: item.datapoint[0], text: item.datapoint[0] },
},
item
);
return {
label: linkUiModel.title,
url: linkUiModel.href,
......
......@@ -24,9 +24,10 @@ import {
DisplayValue,
fieldReducers,
KeyValue,
LinkModel,
} from '@grafana/data';
import { auto } from 'angular';
import { LinkSrv, LinkModel } from 'app/features/panel/panellinks/link_srv';
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { PanelQueryRunnerFormat } from 'app/features/dashboard/state/PanelQueryRunner';
import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState';
......@@ -328,7 +329,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
const $sanitize = this.$sanitize;
const panel = ctrl.panel;
const templateSrv = this.templateSrv;
let linkInfo: LinkModel | null = null;
let linkInfo: LinkModel<any> | null = null;
const $panelContainer = elem.find('.panel-container');
elem = elem.find('.singlestat-panel');
......@@ -592,7 +593,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
elem.toggleClass('pointer', panel.links.length > 0);
if (panel.links.length > 0) {
linkInfo = linkSrv.getDataLinkUIModel(panel.links[0], data.scopedVars);
linkInfo = linkSrv.getDataLinkUIModel(panel.links[0], data.scopedVars, {});
} else {
linkInfo = null;
}
......
......@@ -9,13 +9,18 @@ import {
FieldDisplayEditor,
FieldPropertiesEditor,
PanelOptionsGroup,
DataLinksEditor,
} from '@grafana/ui';
import { Threshold, ValueMapping, FieldConfig } from '@grafana/data';
import { Threshold, ValueMapping, FieldConfig, DataLink } from '@grafana/data';
import { SingleStatOptions, SparklineOptions } from './types';
import { ColoringEditor } from './ColoringEditor';
import { FontSizeEditor } from './FontSizeEditor';
import { SparklineEditor } from './SparklineEditor';
import {
getDataLinksVariableSuggestions,
getCalculationValueDataLinksVariableSuggestions,
} from 'app/features/panel/panellinks/link_srv';
export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) => {
......@@ -53,10 +58,20 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
});
};
onDataLinksChanged = (links: DataLink[]) => {
this.onDefaultsChange({
...this.props.options.fieldOptions.defaults,
links,
});
};
render() {
const { options } = this.props;
const { fieldOptions } = options;
const { defaults } = fieldOptions;
const suggestions = fieldOptions.values
? getDataLinksVariableSuggestions()
: getCalculationValueDataLinksVariableSuggestions();
return (
<>
......@@ -77,6 +92,15 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={defaults.mappings} />
<PanelOptionsGroup title="Data links">
<DataLinksEditor
value={defaults.links}
onChange={this.onDataLinksChanged}
suggestions={suggestions}
maxLinks={10}
/>
</PanelOptionsGroup>
</>
);
}
......
......@@ -6,8 +6,16 @@ import { config } from 'app/core/config';
// Types
import { SingleStatOptions } from './types';
import { PanelProps, getFieldDisplayValues, VizRepeater, FieldDisplay, BigValue } from '@grafana/ui';
import {
PanelProps,
getFieldDisplayValues,
VizRepeater,
FieldDisplay,
BigValue,
DataLinksContextMenu,
} from '@grafana/ui';
import { BigValueSparkline } from '@grafana/ui/src/components/BigValue/BigValue';
import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> {
renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
......@@ -23,7 +31,23 @@ export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>
};
}
return <BigValue value={value.display} sparkline={sparkline} width={width} height={height} theme={config.theme} />;
return (
<DataLinksContextMenu links={getFieldLinksSupplier(value)}>
{({ openMenu, targetClassName }) => {
return (
<BigValue
value={value.display}
sparkline={sparkline}
width={width}
height={height}
theme={config.theme}
onClick={openMenu}
className={targetClassName}
/>
);
}}
</DataLinksContextMenu>
);
};
getValues = (): FieldDisplay[] => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment