Commit 6644673b by Johannes Edmeier

Add tabular view for metrics

parent c5f4b1c5
......@@ -9,10 +9,10 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@fortawesome/fontawesome": "^1.1.7",
"@fortawesome/fontawesome-free-brands": "^5.0.12",
"@fortawesome/fontawesome-free-regular": "^5.0.12",
"@fortawesome/fontawesome-free-solid": "^5.0.12",
"@fortawesome/fontawesome": "^1.1.8",
"@fortawesome/fontawesome-free-brands": "^5.0.13",
"@fortawesome/fontawesome-free-regular": "^5.0.13",
"@fortawesome/fontawesome-free-solid": "^5.0.13",
"@fortawesome/vue-fontawesome": "0.0.22",
"axios": "^0.18.0",
"bulma": "^0.7.1",
......@@ -30,7 +30,7 @@
"moment": "^2.22.1",
"moment-shortformat": "^2.1.0",
"popper.js": "^1.14.3",
"pretty-bytes": "^4.0.2",
"pretty-bytes": "^5.0.0",
"resize-observer-polyfill": "^1.5.0",
"rxjs": "^5.5.10",
"vue": "^2.5.16",
......@@ -39,18 +39,18 @@
"yamljs": "^0.3.0"
},
"devDependencies": {
"@vue/test-utils": "^1.0.0-beta.15",
"autoprefixer": "^8.4.1",
"@vue/test-utils": "^1.0.0-beta.16",
"autoprefixer": "^8.5.0",
"babel-core": "^6.26.3",
"babel-eslint": "^8.2.3",
"babel-jest": "^22.4.3",
"babel-loader": "^7.1.4",
"babel-plugin-lodash": "^3.3.2",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-env": "^1.7.0",
"babel-preset-stage-2": "^6.24.1",
"clean-webpack-plugin": "^0.1.19",
"cross-env": "^5.1.4",
"cross-env": "^5.1.5",
"css-hot-loader": "^1.3.9",
"css-loader": "^0.28.11",
"css-mqpacker": "^6.0.2",
......@@ -68,7 +68,7 @@
"lodash-webpack-plugin": "^0.11.5",
"node-sass": "^4.9.0",
"optimize-css-assets-webpack-plugin": "^3.2.0",
"postcss-loader": "^2.1.4",
"postcss-loader": "^2.1.5",
"sass-loader": "^6.0.7",
"style-loader": "^0.20.3",
"url-loader": "^0.6.2",
......@@ -76,8 +76,8 @@
"vue-loader": "^14.2.2",
"vue-svg-loader": "^0.5.0",
"vue-template-compiler": "^2.5.16",
"webpack": "^3.11.0",
"webpack-bundle-analyzer": "^2.11.1"
"webpack": "^3.12.0",
"webpack-bundle-analyzer": "^2.11.2"
},
"browserslist": [
"> 2%",
......
......@@ -81,6 +81,14 @@ body {
}
}
p.is-loading {
&::before {
display: inline-block;
right: 0.25em;
@include loader;
}
}
//hero as card-header
.card .hero {
padding: 0.75rem;
......
......@@ -16,8 +16,14 @@
<template>
<div class="card panel">
<header v-if="title" class="card-header">
<p v-text="title" class="card-header-title"/>
<header v-if="title || $slots['header']" class="card-header">
<p class="card-header-title">
<span v-text="title"/>
<slot name="header"/>
</p>
<div class="panel__close">
<sba-icon-button v-if="closeable" :icon="['far', 'times-circle']" @click.stop="close"/>
</div>
</header>
<div v-if="$slots['default']" class="card-content">
<slot/>
......@@ -26,18 +32,40 @@
</template>
<script>
import SbaIconButton from './sba-icon-button';
export default {
components: {SbaIconButton},
props: {
title: {
type: String,
required: true
},
closeable: {
type: Boolean,
default: false
}
},
methods: {
close(event) {
this.$emit('close', event);
}
}
}
</script>
<style lang="scss">
@import "~@/assets/css/utilities";
.panel {
margin-bottom: 1.5rem;
&__close {
margin-right: 0.75em;
color: $grey-light;
display: flex;
align-items: center;
justify-self: flex-end;
}
}
</style>
......@@ -19,7 +19,7 @@ import Popper from 'popper.js';
const poppers = new WeakMap();
const bind = (el, binding) => {
const reference = document.getElementById(binding.value);
const reference = typeof binding.value === 'string' ? document.getElementById(binding.value) : binding.value;
if (reference) {
const popper = new Popper(reference, el);
poppers.set(el, popper);
......
......@@ -55,7 +55,12 @@ class Instance {
}
async fetchMetric(metric, tags) {
const params = tags ? {tag: _.entries(tags).map(([name, value]) => `${name}:${value}`).join(',')} : {};
const params = tags ? {
tag: _.entries(tags)
.filter(([, value]) => typeof value !== 'undefined' && value !== null)
.map(([name, value]) => `${name}:${value}`)
.join(',')
} : {};
return axios.get(uri`instances/${this.id}/actuator/metrics/${metric}`, {
headers: {'Accept': actuatorMimeTypes},
params
......
/*
* Copyright 2014-2018 the original author or authors.
*
* 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.
*/
export const compareBy = mapper => (a, b) => {
const valA = mapper(a);
const valB = mapper(b);
return valA > valB ? 1 : valA < valB ? -1 : 0;
};
......@@ -29,8 +29,8 @@
</span>
</p>
</div>
<div class="field">
<div class="control is-pulled-right">
<div class="field is-grouped is-grouped-right">
<div class="control">
<button class="button is-warning" :class="{'is-loading' : actionState === 'executing'}"
@click.stop="addFilter"><font-awesome-icon icon="bell-slash"/>&nbsp;Suppress
</button>
......@@ -44,8 +44,8 @@
<strong v-text="activeFilter.expiry ? activeFilter.expiry.locale('en').fromNow(true) : 'ever' "/>.
</p>
</div>
<div class="field">
<div class="control is-pulled-right">
<div class="field is-grouped is-grouped-right">
<div class="control">
<button class="button" :class="{'is-loading' : actionState === 'executing'}" @click.stop="deleteActiveFilter">
<font-awesome-icon icon="bell"/>&nbsp;Unsuppress
</button>
......
......@@ -14,6 +14,7 @@
* limitations under the License.
*/
import {compareBy} from '@/utils/collections';
import {view as aboutView} from './about';
import {view as applicationView} from './applications';
import instanceViews from './instances';
......@@ -52,7 +53,7 @@ export default router => {
views.register(aboutView);
views.register(wallboardView);
instanceViews.forEach(views.register);
views.sort((a, b) => a.order - b.order);
views.sort(compareBy(v => v.order));
router.addRoutes([{path: '/', redirect: {name: 'applications'}}]);
......
......@@ -23,6 +23,7 @@ import sbaInstancesJolokia from './jolokia';
import sbaInstancesLiquibase from './liquibase';
import sbaInstancesLogfile from './logfile';
import sbaInstancesLoggers from './loggers';
import sbaInstancesMetrics from './metrics';
import sbaInstancesSessions from './sessions';
import sbaInstancesShell from './shell';
import sbaInstancesThreaddump from './threaddump';
......@@ -32,6 +33,8 @@ export default [{
children: [{
path: '', component: sbaInstancesDetails, props: true, name: 'instance/details'
}, {
path: 'metrics', component: sbaInstancesMetrics, props: true, name: 'instance/metrics'
}, {
path: 'env', component: sbaInstancesEnv, props: true, name: 'instance/env'
}, {
path: 'logfile', component: sbaInstancesLogfile, props: true, name: 'instance/logfile'
......@@ -57,6 +60,11 @@ export default [{
handle: 'Details',
order: 0
}, {
name: 'instance/metrics',
handle: 'Metrics',
order: 50,
isActive: ({instance}) => instance.hasEndpoint('metrics')
}, {
name: 'instance/env',
handle: 'Environment',
order: 100,
......
<!--
- Copyright 2014-2018 the original author or authors.
-
- 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.
-->
<template>
<section class="section">
<div class="container">
<div v-if="error" class="message is-danger">
<div class="message-body">
<strong>
<font-awesome-icon class="has-text-danger" icon="exclamation-triangle"/>
Fetching metrics failed.
</strong>
<p v-text="error.message"/>
</div>
</div>
<form @submit.prevent="handleSubmit" class="field">
<div class="field" v-if="availableMetrics.length > 0">
<div class="control">
<div class="select">
<select v-model="selectedMetric">
<option v-for="metric in availableMetrics" v-text="metric" :key="metric"/>
</select>
</div>
</div>
</div>
<div>
<p v-if="stateFetchingTags === 'executing'" class="is-loading">Fetching available tags</p>
<div class="box" v-if="availableTags">
<div class="field is-horizontal" v-for="tag in availableTags" :key="tag.tag">
<div class="field-label">
<label class="label" v-text="tag.tag"/>
</div>
<div class="field-body">
<div class="control">
<div class="select">
<select v-model="selectedTags[tag.tag]">
<option :value="undefined">-</option>
<option v-for="value in tag.values" :key="value" :value="value" v-text="value"/>
</select>
</div>
</div>
</div>
</div>
<p v-if="availableTags && availableTags.length === 0">
No tags available.
</p>
<div class="field is-grouped is-grouped-right">
<div class="control">
<button type="submit" class="button is-primary">Add Metric</button>
</div>
</div>
</div>
</div>
</form>
<metric v-for="metric in metrics"
:key="metric.name"
:metric-name="metric.name"
:tag-selections="metric.tagSelections"
:instance="instance"
@remove="removeMetric"
/>
</div>
</section>
</template>
<script>
import Instance from '@/services/instance';
import _ from 'lodash';
import Metric from './metric';
export default {
components: {Metric},
props: {
instance: {
type: Instance,
required: true
}
},
data: () => ({
metrics: [],
error: null,
availableMetrics: [],
selectedMetric: null,
stateFetchingTags: null,
availableTags: null,
selectedTags: null
}),
created() {
this.fetchMetricIndex();
},
watch: {
selectedMetric: 'fetchAvailableTags'
},
methods: {
handleSubmit() {
this.addMetric(this.selectedMetric, this.selectedTags)
},
removeMetric(metricName, idxTagSelection) {
const idxMetric = this.metrics.findIndex(m => m.name === metricName);
if (idxMetric >= 0) {
const metric = this.metrics[idxMetric];
if (idxTagSelection < metric.tagSelections.length) {
metric.tagSelections.splice(idxTagSelection, 1);
}
if (metric.tagSelections.length === 0) {
this.metrics.splice(idxMetric, 1)
}
}
},
addMetric(metricName, tagSelection = {}) {
if (metricName) {
const metric = this.metrics.find(m => m.name === metricName);
if (metric) {
metric.tagSelections = [...metric.tagSelections, {...tagSelection}]
} else {
this.metrics = _.sortBy([...this.metrics, {
name: metricName,
tagSelections: [{...tagSelection}]
}], [m => m.name]);
}
}
},
async fetchMetricIndex() {
this.error = null;
try {
const res = await this.instance.fetchMetrics();
this.availableMetrics = res.data.names;
this.availableMetrics.sort();
this.selectedMetric = this.availableMetrics[0];
} catch (error) {
console.warn('Fetching metric index failed:', error);
this.hasLoaded = true;
this.error = error;
}
},
async fetchAvailableTags(metricName) {
this.availableTags = null;
this.stateFetchingTags = 'executing';
try {
const response = await this.instance.fetchMetric(metricName);
this.availableTags = response.data.availableTags;
this.stateFetchingTags = 'completed';
this.selectedTags = {};
this.availableTags.forEach(t => this.selectedTags[t.tag] = undefined);
} catch (error) {
console.warn('Fetching metric tags failed:', error);
this.stateFetchingTags = 'failed';
}
}
}
}
</script>
<!--
- Copyright 2014-2018 the original author or authors.
-
- 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.
-->
<template>
<table class="metrics table is-fullwidth is-bordered is-narrow">
<thead>
<tr>
<th v-text="metricName"/>
<th class="metrics__statistic-name"
v-for="statistic in statistics"
:key="`head-${statistic.name}`">
<span v-text="statistic.name"/>
<div class="select is-small is-pulled-right">
<select v-model="statistic.type">
<option :value="undefined">-</option>
<option value="integer">Integer</option>
<option value="float">Float</option>
<option value="duration">Duration</option>
<option value="millis">Milliseconds</option>
<option value="bytes">Bytes</option>
</select>
</div>
</th>
<td/>
</tr>
</thead>
<tbody>
<tr v-for="(tags, idx) in tagSelections" :key="idx">
<td>
<span v-text="getLabel(tags)"/>
<span class="has-text-warning" v-if="errors[idx]" :title="errors[idx]">
<font-awesome-icon icon="exclamation-triangle"/>
</span>
</td>
<td class="metrics__statistic-value"
v-for="statistic in statistics"
:key="`value-${idx}-${statistic.name}`"
v-text="getValue(measurements[idx], statistic)"
/>
<td>
<sba-icon-button :icon="'trash'" @click.stop="handleRemove(idx)"/>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import subscribing from '@/mixins/subscribing';
import Instance from '@/services/instance';
import {Observable} from '@/utils/rxjs';
import _ from 'lodash';
import moment from 'moment';
import prettyBytes from 'pretty-bytes';
const formatDuration = value => {
const duration = moment.duration(value * 1000);
return `${Math.floor(duration.asDays())}d ${duration.hours()}h ${duration.minutes()}m ${duration.seconds()}s`;
};
const formatMillis = value => {
return `${moment.duration(value * 1000).asMilliseconds().toFixed(0)} ms`;
};
export default {
name: 'Metric',
mixins: [subscribing],
props: {
metricName: {
type: String,
required: true
},
instance: {
type: Instance,
required: true
},
tagSelections: {
type: Array,
default: () => [{}]
}
},
data: () => ({
measurements: [],
statistics: [],
errors: [],
}),
methods: {
handleRemove(idx) {
this.$emit('remove', this.metricName, idx);
},
getValue(measurements, statistic) {
const measurement = measurements && measurements.find(m => m.statistic === statistic.name);
if (!measurement) {
return undefined;
}
const type = statistic && statistic.type;
switch (type) {
case 'integer':
return measurement.value.toFixed(0);
case 'float':
return measurement.value.toFixed(4);
case 'duration':
return formatDuration(measurement.value);
case 'millis':
return formatMillis(measurement.value);
case 'bytes':
return prettyBytes(measurement.value);
default:
return measurement.value;
}
},
getLabel(tags) {
return _.entries(tags).filter(([, value]) => typeof value !== 'undefined')
.map(pair => pair.join('='))
.join(' ');
},
async fetchMetric(tags, idx) {
try {
const response = await this.instance.fetchMetric(this.metricName, tags);
this.$set(this.errors, idx, null);
this.$set(this.measurements, idx, response.data.measurements);
if (idx === 0) {
response.data.measurements.map(m => m.statistic)
.filter(s => !this.statistics.some(stat => stat.name === s))
.map(s => ({name: s, type: undefined}))
.forEach(s => this.statistics.push(s));
}
} catch (error) {
console.warn(`Fetching metric ${this.metricName} failed:`, error);
this.errors[idx] = error;
}
},
fetchAllTags() {
return Observable.from(this.tagSelections).concatMap(this.fetchMetric);
},
createSubscription() {
const vm = this;
return Observable.timer(0, 2500)
.concatMap(vm.fetchAllTags)
.subscribe({
next: () => {
}
});
}
},
watch: {
tagSelections(newVal, oldVal) {
newVal.map((v, i) => [v, i])
.filter(([v]) => !oldVal.includes(v))
.forEach(([v, i]) => this.fetchMetric(v, i));
}
}
}
</script>
<style lang="scss">
table.metrics {
table-layout: fixed;
}
.metrics {
&__statistic-name * {
vertical-align: middle;
}
&__statistic-value {
text-align: right;
}
}
</style>
......@@ -63,6 +63,7 @@
<script>
import subscribing from '@/mixins/subscribing';
import Instance from '@/services/instance';
import {compareBy} from '@/utils/collections';
import _ from 'lodash';
import moment from 'moment';
......@@ -120,7 +121,7 @@
try {
this.addEvents((await Instance.fetchEvents()).data);
this.error = null;
this.events.sort((a, b) => b.timestamp - a.timestamp)
this.events.sort(compareBy(v => v.timestamp));
} catch (error) {
console.warn('Fetching events failed:', error);
this.error = error;
......
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