Commit 4be56cde by Kyle Brandt Committed by GitHub

Azure: Multiple dimension support for Azure Monitor Service (#25947)

Azure Monitor (metrics) support multiple dimensions instead of just one.

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
parent 72fa5ccb
......@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"path"
"sort"
"strings"
"time"
......@@ -21,7 +22,6 @@ import (
opentracing "github.com/opentracing/opentracing-go"
"golang.org/x/net/context/ctxhttp"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb"
)
......@@ -57,7 +57,6 @@ func (e *AzureMonitorDatasource) executeTimeSeriesQuery(ctx context.Context, ori
if err != nil {
return nil, err
}
// azlog.Debug("AzureMonitor", "Response", resp)
err = e.parseResponse(queryRes, resp, query)
if err != nil {
......@@ -130,10 +129,25 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange *
params.Add("metricnames", azJSONModel.MetricName) // MetricName or MetricNames ?
params.Add("metricnamespace", azJSONModel.MetricNamespace)
// old model
dimension := strings.TrimSpace(azJSONModel.Dimension)
dimensionFilter := strings.TrimSpace(azJSONModel.DimensionFilter)
if dimension != "" && dimensionFilter != "" && dimension != "None" {
params.Add("$filter", fmt.Sprintf("%s eq '%s'", dimension, dimensionFilter))
dimSB := strings.Builder{}
if dimension != "" && dimensionFilter != "" && dimension != "None" && len(azJSONModel.DimensionsFilters) == 0 {
dimSB.WriteString(fmt.Sprintf("%s eq '%s'", dimension, dimensionFilter))
} else {
for i, filter := range azJSONModel.DimensionsFilters {
dimSB.WriteString(filter.String())
if i != len(azJSONModel.DimensionsFilters)-1 {
dimSB.WriteString(" and ")
}
}
}
if dimSB.String() != "" {
params.Add("$filter", dimSB.String())
params.Add("top", azJSONModel.Top)
}
......@@ -157,7 +171,7 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange *
}
func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *AzureMonitorQuery, queries []*tsdb.Query, timeRange *tsdb.TimeRange) (*tsdb.QueryResult, AzureMonitorResponse, error) {
queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: query.RefID}
queryResult := &tsdb.QueryResult{RefId: query.RefID}
req, err := e.createRequest(ctx, e.dsInfo)
if err != nil {
......@@ -167,7 +181,6 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *AzureM
req.URL.Path = path.Join(req.URL.Path, query.URL)
req.URL.RawQuery = query.Params.Encode()
queryResult.Meta.Set("rawQuery", req.URL.RawQuery)
span, ctx := opentracing.StartSpanFromContext(ctx, "azuremonitor query")
span.SetTag("target", query.Target)
......@@ -270,20 +283,23 @@ func (e *AzureMonitorDatasource) parseResponse(queryRes *tsdb.QueryResult, amr A
frames := data.Frames{}
for _, series := range amr.Value[0].Timeseries {
metadataName := ""
metadataValue := ""
if len(series.Metadatavalues) > 0 {
metadataName = series.Metadatavalues[0].Name.LocalizedValue
metadataValue = series.Metadatavalues[0].Value
labels := data.Labels{}
for _, md := range series.Metadatavalues {
labels[md.Name.LocalizedValue] = md.Value
}
metricName := formatAzureMonitorLegendKey(query.Alias, query.UrlComponents["resourceName"], amr.Value[0].Name.LocalizedValue, metadataName, metadataValue, amr.Namespace, amr.Value[0].ID)
frame := data.NewFrameOfFieldTypes("", len(series.Data), data.FieldTypeTime, data.FieldTypeFloat64)
frame.RefID = query.RefID
frame.Fields[1].Name = metricName
frame.Fields[1].SetConfig(&data.FieldConfig{
dataField := frame.Fields[1]
dataField.Name = amr.Value[0].Name.LocalizedValue
dataField.Labels = labels
dataField.SetConfig(&data.FieldConfig{
Unit: amr.Value[0].Unit,
})
if query.Alias != "" {
dataField.Config.DisplayName = formatAzureMonitorLegendKey(query.Alias, query.UrlComponents["resourceName"],
amr.Value[0].Name.LocalizedValue, "", "", amr.Namespace, amr.Value[0].ID, labels)
}
requestedAgg := query.Params.Get("aggregation")
......@@ -317,14 +333,7 @@ func (e *AzureMonitorDatasource) parseResponse(queryRes *tsdb.QueryResult, amr A
// formatAzureMonitorLegendKey builds the legend key or timeseries name
// Alias patterns like {{resourcename}} are replaced with the appropriate data values.
func formatAzureMonitorLegendKey(alias string, resourceName string, metricName string, metadataName string, metadataValue string, namespace string, seriesID string) string {
if alias == "" {
if len(metadataName) > 0 {
return fmt.Sprintf("%s{%s=%s}.%s", resourceName, metadataName, metadataValue, metricName)
}
return fmt.Sprintf("%s.%s", resourceName, metricName)
}
func formatAzureMonitorLegendKey(alias string, resourceName string, metricName string, metadataName string, metadataValue string, namespace string, seriesID string, labels data.Labels) string {
startIndex := strings.Index(seriesID, "/resourceGroups/") + 16
endIndex := strings.Index(seriesID, "/providers")
resourceGroup := seriesID[startIndex:endIndex]
......@@ -350,14 +359,25 @@ func formatAzureMonitorLegendKey(alias string, resourceName string, metricName s
return []byte(metricName)
}
keys := make([]string, 0, len(labels))
if metaPartName == "dimensionname" || metaPartName == "dimensionvalue" {
for k := range labels {
keys = append(keys, k)
}
keys = sort.StringSlice(keys)
}
if metaPartName == "dimensionname" {
return []byte(metadataName)
return []byte(keys[0])
}
if metaPartName == "dimensionvalue" {
return []byte(metadataValue)
return []byte(labels[keys[0]])
}
if v, ok := labels[metaPartName]; ok {
return []byte(v)
}
return in
})
......
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"cost": 0,
"timespan": "2020-06-30T09:58:58Z/2020-06-30T12:58:58Z",
"interval": "PT1H",
"value": [
{
"id": "/subscriptions/44693801-6ee6-49de-9b2d-9106972f9572/resourceGroups/danieltest/providers/Microsoft.Storage/storageAccounts/danieltestdiag187/blobServices/default/providers/Microsoft.Insights/metrics/BlobCapacity",
"type": "Microsoft.Insights/metrics",
"name": {
"value": "BlobCapacity",
"localizedValue": "Blob Capacity"
},
"displayDescription": "The amount of storage used by the storage account’s Blob service in bytes.",
"unit": "Bytes",
"timeseries": [
{
"metadatavalues": [
{
"name": {
"value": "blobtype",
"localizedValue": "blobtype"
},
"value": "PageBlob"
},
{
"name": {
"value": "tier",
"localizedValue": "tier"
},
"value": "Standard"
}
],
"data": [
{
"timeStamp": "2020-06-30T09:58:00Z",
"average": 675530
},
{
"timeStamp": "2020-06-30T10:58:00Z",
"average": 675530
},
{
"timeStamp": "2020-06-30T11:58:00Z",
"average": 675530
}
]
},
{
"metadatavalues": [
{
"name": {
"value": "blobtype",
"localizedValue": "blobtype"
},
"value": "BlockBlob"
},
{
"name": {
"value": "tier",
"localizedValue": "tier"
},
"value": "Hot"
}
],
"data": [
{
"timeStamp": "2020-06-30T09:58:00Z",
"average": 0
},
{
"timeStamp": "2020-06-30T10:58:00Z",
"average": 0
},
{
"timeStamp": "2020-06-30T11:58:00Z",
"average": 0
}
]
},
{
"metadatavalues": [
{
"name": {
"value": "blobtype",
"localizedValue": "blobtype"
},
"value": "Azure Data Lake Storage"
},
{
"name": {
"value": "tier",
"localizedValue": "tier"
},
"value": "Cool"
}
],
"data": [
{
"timeStamp": "2020-06-30T09:58:00Z",
"average": 0
},
{
"timeStamp": "2020-06-30T10:58:00Z",
"average": 0
},
{
"timeStamp": "2020-06-30T11:58:00Z",
"average": 0
}
]
}
],
"errorCode": "Success"
}
],
"namespace": "Microsoft.Storage/storageAccounts/blobServices",
"resourceregion": "westeurope"
}
\ No newline at end of file
......@@ -87,8 +87,8 @@ type azureMonitorJSONQuery struct {
Aggregation string `json:"aggregation"`
Alias string `json:"alias"`
AllowedTimeGrainsMs []int64 `json:"allowedTimeGrainsMs"`
Dimension string `json:"dimension"`
DimensionFilter string `json:"dimensionFilter"`
Dimension string `json:"dimension"` // old model
DimensionFilter string `json:"dimensionFilter"` // old model
Format string `json:"format"`
MetricDefinition string `json:"metricDefinition"`
MetricName string `json:"metricName"`
......@@ -97,10 +97,24 @@ type azureMonitorJSONQuery struct {
ResourceName string `json:"resourceName"`
TimeGrain string `json:"timeGrain"`
Top string `json:"top"`
DimensionsFilters []azureMonitorDimensionFilter `json:"dimensionsFilters"` // new model
} `json:"azureMonitor"`
Subscription string `json:"subscription"`
}
// azureMonitorDimensionFilter is the model for the frontend sent for azureMonitor metric
// queries like "BlobType", "eq", "*"
type azureMonitorDimensionFilter struct {
Dimension string `json:"dimension"`
Operator string `json:"operator"`
Filter string `json:"filter"`
}
func (a azureMonitorDimensionFilter) String() string {
return fmt.Sprintf("%v %v '%v'", a.Dimension, a.Operator, a.Filter)
}
// insightsJSONQuery is the frontend JSON query model for an Azure Application Insights query.
type insightsJSONQuery struct {
AppInsights struct {
......
......@@ -74,11 +74,18 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
const aggregation = templateSrv.replace(item.aggregation, scopedVars);
const top = templateSrv.replace(item.top || '', scopedVars);
const dimensionsFilters = item.dimensionFilters.map(f => {
return {
dimension: templateSrv.replace(f.dimension, scopedVars),
operator: f.operator || 'eq',
filter: templateSrv.replace(f.filter, scopedVars),
};
});
return {
refId: target.refId,
subscription: subscriptionId,
queryType: AzureQueryType.AzureMonitor,
type: 'timeSeriesQuery',
azureMonitor: {
resourceGroup,
resourceName,
......@@ -89,9 +96,8 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
metricNamespace:
metricNamespace && metricNamespace !== defaultDropdownValue ? metricNamespace : metricDefinition,
aggregation: aggregation,
dimension: templateSrv.replace(item.dimension, scopedVars),
dimensionsFilters,
top: top || '10',
dimensionFilter: templateSrv.replace(item.dimensionFilter, scopedVars),
alias: item.alias,
format: target.format,
},
......
......@@ -13,6 +13,7 @@ import {
import { Observable, of, from } from 'rxjs';
import { DataSourceWithBackend } from '@grafana/runtime';
import InsightsAnalyticsDatasource from './insights_analytics/insights_analytics_datasource';
import { migrateMetricsDimensionFilters } from './query_ctrl';
export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDataSourceJsonData> {
azureMonitorDatasource: AzureMonitorDatasource;
......@@ -64,6 +65,10 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
target.queryType = AzureQueryType.AzureMonitor;
}
if (target.queryType === AzureQueryType.AzureMonitor) {
migrateMetricsDimensionFilters(target.azureMonitor);
}
// Check that we have options
const opts = (target as any)[this.optionsKey[target.queryType]];
......
......@@ -132,44 +132,73 @@
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-show="ctrl.target.azureMonitor.dimensions.length > 0">
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Dimension</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<select
class="gf-form-input min-width-12"
ng-model="ctrl.target.azureMonitor.dimension"
ng-options="f.value as f.text for f in ctrl.target.azureMonitor.dimensions"
ng-change="ctrl.refresh()"
></select>
<!-- NO Filters-->
<ng-container ng-if="ctrl.target.azureMonitor.dimensionFilters.length < 1">
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Dimension</label>
</div>
<div class="gf-form">
<a ng-click="ctrl.azureMonitorAddDimensionFilter()" class="gf-form-label query-part"><icon name="'plus'"></icon></a>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-3">eq</label>
<input
type="text"
class="gf-form-input width-17"
ng-model="ctrl.target.azureMonitor.dimensionFilter"
spellcheck="false"
placeholder="auto"
ng-blur="ctrl.refresh()"
/>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Top</label>
<input
type="text"
class="gf-form-input width-3"
ng-model="ctrl.target.azureMonitor.top"
spellcheck="false"
placeholder="10"
ng-blur="ctrl.refresh()"
/>
</ng-container>
<!-- YES Filters-->
<ng-container ng-if="ctrl.target.azureMonitor.dimensionFilters.length > 0">
<div ng-repeat="dim in ctrl.target.azureMonitor.dimensionFilters track by $index" class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Dimension</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<select
class="gf-form-input min-width-12"
ng-model="dim.dimension"
ng-options="f.value as f.text for f in ctrl.target.azureMonitor.dimensions"
ng-change="ctrl.refresh()"
></select>
</div>
<label class="gf-form-label query-keyword width-3">eq</label>
<input
type="text"
class="gf-form-input width-17"
ng-model="dim.filter"
spellcheck="false"
placeholder="Anything (*)"
ng-blur="ctrl.refresh()"
/>
</div>
<div class="gf-form">
<a ng-click="ctrl.azureMonitorRemoveDimensionFilter($index)" class="gf-form-label query-part"><icon name="'minus'"></icon></a>
</div>
<div class="gf-form" ng-if="$last">
<a ng-click="ctrl.azureMonitorAddDimensionFilter()" class="gf-form-label query-part"><icon name="'plus'"></icon></a>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Top</label>
<input
type="text"
class="gf-form-input width-3"
ng-model="ctrl.target.azureMonitor.top"
spellcheck="false"
placeholder="10"
ng-blur="ctrl.refresh()"
/>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</div>
</ng-container>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Legend Format</label>
......
......@@ -8,7 +8,7 @@ import kbn from 'app/core/utils/kbn';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { auto, IPromise } from 'angular';
import { DataFrame, PanelEvents } from '@grafana/data';
import { AzureQueryType } from './types';
import { AzureQueryType, AzureMetricQuery } from './types';
export interface ResultFormat {
text: string;
......@@ -27,23 +27,7 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
refId: string;
queryType: AzureQueryType;
subscription: string;
azureMonitor: {
resourceGroup: string;
resourceName: string;
metricDefinition: string;
metricNamespace: string;
metricName: string;
dimensionFilter: string;
timeGrain: string;
timeGrainUnit: string;
allowedTimeGrainsMs: number[];
dimensions: any[];
dimension: any;
top: string;
aggregation: string;
aggOptions: string[];
timeGrains: Array<{ text: string; value: string }>;
};
azureMonitor: AzureMetricQuery;
azureLogAnalytics: {
query: string;
resultFormat: string;
......@@ -139,6 +123,8 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
this.migrateApplicationInsightsDimensions();
migrateMetricsDimensionFilters(this.target.azureMonitor);
this.panelCtrl.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this), $scope);
this.panelCtrl.events.on(PanelEvents.dataError, this.onDataError.bind(this), $scope);
this.resultFormats = [
......@@ -219,12 +205,13 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
}
}
const oldAzureTimeGrains = (this.target.azureMonitor as any).timeGrains;
if (
this.target.azureMonitor.timeGrains &&
this.target.azureMonitor.timeGrains.length > 0 &&
oldAzureTimeGrains &&
oldAzureTimeGrains.length > 0 &&
(!this.target.azureMonitor.allowedTimeGrainsMs || this.target.azureMonitor.allowedTimeGrainsMs.length === 0)
) {
this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(this.target.azureMonitor.timeGrains);
this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(oldAzureTimeGrains);
}
if (
......@@ -328,10 +315,8 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
this.target.azureMonitor.resourceName = this.defaultDropdownValue;
this.target.azureMonitor.metricName = this.defaultDropdownValue;
this.target.azureMonitor.aggregation = '';
this.target.azureMonitor.timeGrains = [];
this.target.azureMonitor.timeGrain = '';
this.target.azureMonitor.dimensions = [];
this.target.azureMonitor.dimension = '';
this.target.azureMonitor.dimensionFilters = [];
}
}
......@@ -439,10 +424,8 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
this.target.azureMonitor.metricNamespace = this.defaultDropdownValue;
this.target.azureMonitor.metricName = this.defaultDropdownValue;
this.target.azureMonitor.aggregation = '';
this.target.azureMonitor.timeGrains = [];
this.target.azureMonitor.timeGrain = '';
this.target.azureMonitor.dimensions = [];
this.target.azureMonitor.dimension = '';
this.target.azureMonitor.dimensionFilters = [];
this.refresh();
}
......@@ -451,27 +434,22 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
this.target.azureMonitor.metricNamespace = this.defaultDropdownValue;
this.target.azureMonitor.metricName = this.defaultDropdownValue;
this.target.azureMonitor.aggregation = '';
this.target.azureMonitor.timeGrains = [];
this.target.azureMonitor.timeGrain = '';
this.target.azureMonitor.dimensions = [];
this.target.azureMonitor.dimension = '';
this.target.azureMonitor.dimensionFilters = [];
}
onResourceNameChange() {
this.target.azureMonitor.metricNamespace = this.defaultDropdownValue;
this.target.azureMonitor.metricName = this.defaultDropdownValue;
this.target.azureMonitor.aggregation = '';
this.target.azureMonitor.timeGrains = [];
this.target.azureMonitor.timeGrain = '';
this.target.azureMonitor.dimensions = [];
this.target.azureMonitor.dimension = '';
this.target.azureMonitor.dimensionFilters = [];
this.refresh();
}
onMetricNamespacesChange() {
this.target.azureMonitor.metricName = this.defaultDropdownValue;
this.target.azureMonitor.dimensions = [];
this.target.azureMonitor.dimension = '';
this.target.azureMonitor.dimensionFilters = [];
}
onMetricNameChange(): IPromise<void> {
......@@ -489,16 +467,20 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
this.replace(this.target.azureMonitor.metricName)
)
.then((metadata: any) => {
this.target.azureMonitor.aggOptions = metadata.supportedAggTypes || [metadata.primaryAggType];
console.log('Update metadata', metadata);
this.target.azureMonitor.aggregation = metadata.primaryAggType;
this.target.azureMonitor.timeGrains = [{ text: 'auto', value: 'auto' }].concat(metadata.supportedTimeGrains);
this.target.azureMonitor.timeGrain = 'auto';
this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(metadata.supportedTimeGrains || []);
this.target.azureMonitor.dimensions = metadata.dimensions;
// HACK: this saves the last metadata values in the panel json ¯\_(ツ)_/¯
const hackState = this.target.azureMonitor as any;
hackState.aggOptions = metadata.supportedAggTypes || [metadata.primaryAggType];
hackState.timeGrains = [{ text: 'auto', value: 'auto' }].concat(metadata.supportedTimeGrains);
hackState.dimensions = metadata.dimensions;
if (metadata.dimensions.length > 0) {
this.target.azureMonitor.dimension = metadata.dimensions[0].value;
// this.target.azureMonitor.dimension = metadata.dimensions[0].value;
}
return this.refresh();
......@@ -537,13 +519,29 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
}
getAzureMonitorAutoInterval() {
return this.generateAutoUnits(this.target.azureMonitor.timeGrain, this.target.azureMonitor.timeGrains);
return this.generateAutoUnits(this.target.azureMonitor.timeGrain, (this.target.azureMonitor as any).timeGrains);
}
getApplicationInsightAutoInterval() {
return this.generateAutoUnits(this.target.appInsights.timeGrain, this.target.appInsights.timeGrains);
}
azureMonitorAddDimensionFilter() {
console.log('Add dimension', this.target.azureMonitor);
this.target.azureMonitor.dimensionFilters.push({
dimension: '',
operator: 'eq',
filter: '',
});
this.refresh();
}
azureMonitorRemoveDimensionFilter(index: number) {
this.target.azureMonitor.dimensionFilters.splice(index, 1);
this.refresh();
console.log('Remove dimension', index, this.target.azureMonitor);
}
/* Azure Log Analytics */
getWorkspaces = () => {
......@@ -695,3 +693,20 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
this.refresh();
}
}
// Modifies the actual query object
export function migrateMetricsDimensionFilters(item: AzureMetricQuery) {
if (!item.dimensionFilters) {
item.dimensionFilters = [];
}
const oldDimension = (item as any).dimension;
if (oldDimension && oldDimension !== 'None') {
item.dimensionFilters.push({
dimension: oldDimension,
operator: 'eq',
filter: (item as any).dimensionFilter,
});
delete (item as any).dimension;
delete (item as any).dimensionFilter;
}
}
......@@ -45,6 +45,12 @@ export interface AzureDataSourceSecureJsonData {
appInsightsApiKey?: string;
}
export interface AzureMetricDimension {
dimension: string;
operator: 'eq'; // future proof
filter?: string; // *
}
export interface AzureMetricQuery {
resourceGroup: string;
resourceName: string;
......@@ -55,8 +61,7 @@ export interface AzureMetricQuery {
timeGrain: string;
allowedTimeGrainsMs: number[];
aggregation: string;
dimension: string;
dimensionFilter: string;
dimensionFilters: AzureMetricDimension[];
alias: string;
top: string;
}
......
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