Skip to content

Commit

Permalink
Merge pull request #615 from molgenis/feat/metrics
Browse files Browse the repository at this point in the history
feat: use actuator/metrics for data points
  • Loading branch information
clemens-tolboom authored Mar 13, 2024
2 parents e73c1fb + b6f996e commit 422e5a3
Show file tree
Hide file tree
Showing 12 changed files with 440 additions and 15 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ dist
### STORAGE ###
data/user-*
data/system
# Ignore all projects but life-cycle
data/shared-*
!data/shared-lifecycle

HELP.md
target/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.molgenis.armadillo.info;

import io.micrometer.common.lang.NonNull;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.MeterBinder;
import java.io.File;
import org.springframework.stereotype.Component;

@Component
public class FileMetrics implements MeterBinder {

private static final String PATH = System.getProperty("user.dir");

@Override
public void bindTo(@NonNull MeterRegistry registry) {
File folder = new File(PATH);
File[] listOfFiles = folder.listFiles();

int fileCount = 0;
int dirCount = 0;

if (listOfFiles != null) {
for (File file : listOfFiles) {
if (file.isFile()) {
fileCount++;
} else if (file.isDirectory()) {
dirCount++;
}
}
}

Gauge.builder("user.files.count", fileCount, Integer::doubleValue)
.description("Number of files in the current directory")
.baseUnit("files")
.tags("path", PATH)
.register(registry);

Gauge.builder("user.directories.count", dirCount, Integer::doubleValue)
.description("Number of directories in the current directory")
.baseUnit("directories")
.tags("path", PATH)
.register(registry);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
public class RMetrics {

@Bean
MeterBinder rProcesses(ProfileService profileService, RProcessEndpoint processes) {
MeterBinder rProcesses(ProfileService profileService, RProcessEndpoint rProcessEndpoint) {

return registry ->
runAsSystem(
Expand All @@ -24,7 +24,7 @@ MeterBinder rProcesses(ProfileService profileService, RProcessEndpoint processes
environment ->
Gauge.builder(
"rserve.processes.current",
() -> processes.countRServeProcesses(environment))
() -> rProcessEndpoint.countRServeProcesses(environment))
.tag("environment", environment)
.description(
"Current number of RServe processes on the R environment")
Expand Down
61 changes: 59 additions & 2 deletions ui/src/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiError } from "@/helpers/errors";
import { sanitizeObject } from "@/helpers/utils";
import { objectDeepCopy, sanitizeObject } from "@/helpers/utils";
import {
Principal,
Profile,
Expand All @@ -8,7 +8,11 @@ import {
Auth,
RemoteFileInfo,
RemoteFileDetail,
Metric,
HalResponse,
Metrics,
} from "@/types/api";

import { ObjectWithStringKey, StringArray } from "@/types/types";
import { APISettings } from "./config";

Expand Down Expand Up @@ -88,7 +92,7 @@ export async function handleResponse(response: Response) {
}
}

export async function getActuator() {
export async function getActuator(): Promise<HalResponse> {
let result = await get("/actuator");
return result;
}
Expand All @@ -103,6 +107,59 @@ export async function getVersion() {
return result.build.version;
}

/**
* Fetch all metric values on one go using the list.
*/
export async function getMetricsAll(): Promise<Metrics> {
const paths = await getMetrics();
const metrics: Metrics = {};

await Promise.all(
paths.map(async (path) => {
const response = await getMetric(path);
metrics[path] = response;
return { path: response };
})
);

return metrics;
}

/**
* Get list of metric IDs in as dictionary keys.
*/
async function getMetrics(): Promise<string[]> {
const path = "/actuator/metrics";
return await get(path)
.then((data) => {
// Check if the data has 'names' property
if (data.hasOwnProperty("names")) {
return data.names;
} else {
console.log("No names found in the data");
return [];
}
})
.catch((error) => {
console.error(`Error fetching ${path}`, error);
return {};
});
}

/**
* Fetches give Metric ID.
*
* @path: dot separated string
*
* Example: a.b.c
*/
async function getMetric(id: string): Promise<Metric> {
const path = `/actuator/metrics/${id}`;
return get(path).then((data) => {
return objectDeepCopy<Metric>(data);
});
}

export async function deleteUser(email: string) {
return delete_("/access/users", email);
}
Expand Down
175 changes: 175 additions & 0 deletions ui/src/components/Actuator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<template>
<div class="row">
<div class="col mt-3" v-if="isLoading">
<LoadingSpinner />
</div>
<div class="col" v-else>
<div class="row">
<div class="col-sm-3">
<SearchBar id="searchbox" v-model="filterValue" />
</div>
<div class="col">
<button
class="btn btn-primary float-end"
v-if="metrics"
@click="downloadMetrics"
>
<i class="bi bi-box-arrow-down"></i>
Download metrics
</button>
</div>
</div>
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th>key</th>
<th>statistic</th>
<th>value</th>
</tr>
</thead>
<tbody>
<ActuatorItem
v-for="(metric, path, index) in metrics"
:key="index"
:data="metric"
:name="path"
/>
</tbody>
</table>
<hr />
<summary>
<h3>Other Actuator links</h3>
<details>
<table>
<thead>
<tr>
<td>key</td>
<td>href</td>
<td>templated</td>
</tr>
</thead>
<tbody>
<tr v-for="(item, key) in actuator" :key="key">
<td>{{ key }}</td>
<td v-if="item.templated">{{ item.href }}</td>
<td v-if="!item.templated">
<a :href="item.href" target="_new">{{ item.href }}</a>
</td>
<td>{{ item.templated }}</td>
</tr>
</tbody>
</table>
</details>
</summary>
</div>
</div>
</template>

<script setup lang="ts">
import { getActuator, getMetricsAll } from "@/api/api";
import { ref, watch } from "vue";
import { Metrics, HalLinks } from "@/types/api";
import { ObjectWithStringKey } from "@/types/types";
import { objectDeepCopy } from "@/helpers/utils";
import ActuatorItem from "./ActuatorItem.vue";
import SearchBar from "@/components/SearchBar.vue";
import LoadingSpinner from "./LoadingSpinner.vue";
const actuator = ref<HalLinks>();
const metrics = ref<Metrics>([]);
const isLoading = ref<boolean>(true);
const loadActuator = async () => {
let result = (await getActuator())["_links"];
let list = [];
for (let key in result) {
// Add key to each item for further usage
const item = result[key];
item["key"] = key;
list.push(result[key]);
}
actuator.value = list;
};
const loadMetrics = async () => {
metrics.value = await getMetricsAll();
// preload search values
filteredLines();
isLoading.value = false;
};
loadMetrics();
loadActuator();
function downloadJSON(filename: string) {
const cleanedUp = removeFields(metrics.value);
const dataStr =
"data:text/json;charset=utf-8," +
encodeURIComponent(JSON.stringify(cleanedUp));
const downloadAnchorNode = document.createElement("a");
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", filename + ".json");
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
setTimeout(() => downloadAnchorNode.remove(), 10);
}
function downloadMetrics() {
downloadJSON("armadillo-metrics-" + new Date().toISOString());
}
const filterValue = ref("");
watch(filterValue, (_newVal, _oldVal) => filteredLines());
const FIELD_DISPLAY = "_display";
const SEARCH_TEXT_FIELDS = "searchWords";
function concatValues(obj: any): string {
let result = "";
for (const key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
result += concatValues(obj[key]);
} else {
result += obj[key];
}
}
return result;
}
/**
* Filter metrics on search value
*
* We add:
* - string search field for matching
* - booelan display field for storing matched
*/
function filteredLines() {
const filterOn: string = filterValue.value.toLowerCase();
for (let [_key, value] of Object.entries(metrics.value)) {
if (!value[SEARCH_TEXT_FIELDS]) {
value[SEARCH_TEXT_FIELDS] = concatValues(value).toLowerCase();
}
const searchWords: string = value[SEARCH_TEXT_FIELDS];
value[FIELD_DISPLAY] = filterOn === "" || searchWords.includes(filterOn);
}
}
/**
* Remove added fields for searching.
*
* @param json
*/
function removeFields(json: Metrics) {
const result: Metrics = objectDeepCopy<Metrics>(json);
for (let [_key, value] of Object.entries(result)) {
const wrapper: ObjectWithStringKey = value;
delete wrapper[SEARCH_TEXT_FIELDS];
delete wrapper[FIELD_DISPLAY];
}
return result;
}
</script>
56 changes: 56 additions & 0 deletions ui/src/components/ActuatorItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<template>
<tr v-if="data._display" v-for="(v, key) in data.measurements" :key="key">
<td scope="col">{{ key }}</td>
<td :title="data.description">
<span>
{{ data.name }}
<i v-if="data.description" class="bi bi-info-circle-fill"></i>
</span>
</td>
<td>{{ v.statistic }}</td>
<td v-if="data.baseUnit === 'bytes'">
{{ convertBytes(v.value) }}
</td>
<td v-else>{{ v.value }} {{ data.baseUnit }}</td>
</tr>
<tr v-if="data._display">
<td colspan="5">
<summary>
<details>
<pre>
{{ JSON.stringify(data, null, 3) }}
</pre>
</details>
</summary>
</td>
</tr>
</template>

<script setup lang="ts">
const props = defineProps({
name: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
});
/**
* Convert given bytes to 2 digits precision round exponent version string.
* @param bytes number
*/
function convertBytes(bytes: number): string {
const units = ["bytes", "KB", "MB", "GB", "TB", "EB"];
let unitIndex = 0;
while (bytes >= 1024 && unitIndex < units.length - 1) {
bytes /= 1024;
unitIndex++;
}
return `${bytes.toFixed(2)} ${units[unitIndex]}`;
}
</script>
Loading

0 comments on commit 422e5a3

Please sign in to comment.