Skip to content

Commit

Permalink
Add job metrics per invocation
Browse files Browse the repository at this point in the history
  • Loading branch information
mvdbeek committed Nov 4, 2024
1 parent 3ff3843 commit 139994a
Show file tree
Hide file tree
Showing 9 changed files with 423 additions and 1 deletion.
100 changes: 100 additions & 0 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2594,6 +2594,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/invocations/{invocation_id}/metrics": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Invocation Metrics */
get: operations["get_invocation_metrics_api_invocations__invocation_id__metrics_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/invocations/{invocation_id}/prepare_store_download": {
parameters: {
query?: never;
Expand Down Expand Up @@ -18317,6 +18334,45 @@ export interface components {
[key: string]: number;
};
};
/**
* WorkflowJobMetric
* @example {
* "name": "start_epoch",
* "plugin": "core",
* "raw_value": "1614261340.0000000",
* "title": "Job Start Time",
* "value": "2021-02-25 14:55:40"
* }
*/
WorkflowJobMetric: {
/**
* Name
* @description The name of the metric variable.
*/
name: string;
/**
* Plugin
* @description The instrumenter plugin that generated this metric.
*/
plugin: string;
/**
* Raw Value
* @description The raw value of the metric as a string.
*/
raw_value: string;
/**
* Title
* @description A descriptive title for this metric.
*/
title: string;
/** Tool Id */
tool_id: string;
/**
* Value
* @description The textual representation of the metric value.
*/
value: string;
};
/** WorkflowLandingRequest */
WorkflowLandingRequest: {
/** Request State */
Expand Down Expand Up @@ -27021,6 +27077,50 @@ export interface operations {
};
};
};
get_invocation_metrics_api_invocations__invocation_id__metrics_get: {
parameters: {
query?: never;
header?: {
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
"run-as"?: string | null;
};
path: {
/** @description The encoded database identifier of the Invocation. */
invocation_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorkflowJobMetric"][];
};
};
/** @description Request Error */
"4XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
/** @description Server Error */
"5XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
};
};
prepare_store_download_api_invocations__invocation_id__prepare_store_download_post: {
parameters: {
query?: never;
Expand Down
169 changes: 169 additions & 0 deletions client/src/components/WorkflowInvocationState/MetricsBoxPlots.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<template>
<div ref="boxPlot"></div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import * as d3 from 'd3';
import { isTerminal } from './util';
interface JobData {
title: string;
value: string;
plugin: string;
name: string;
raw_value: string;
tool_id: string;
}
const boxPlot = ref<HTMLElement | null>(null);
// Define the job data (this can come from props or API in real scenario// Extract runtime seconds data
// Define props
const props = defineProps<{
jobData: JobData[];
}>();
// Function to render box plots for different tool_ids on the same graph
const renderBoxPlots = () => {
const margin = { top: 10, right: 30, bottom: 50, left: 40 },
width = 800 - margin.left - margin.right,
height = 400 - margin.top - margin.bottom;
// Remove any previous content
d3.select(boxPlot.value).selectAll('*').remove();
// Group data by tool_id
const groupedData = d3.group(props.jobData, d => d.tool_id);
// Extract the tool_ids for the x-axis
const toolIds = Array.from(groupedData.keys());
// Create SVG
const svg = d3
.select(boxPlot.value)
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// X Scale: tool_ids on the x-axis
const x = d3.scaleBand()
.range([0, width])
.domain(toolIds)
.padding(0.4);
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x));
// Y Scale: common for all box plots
const allRuntimes = Array.from(props.jobData)
.filter(d => d.name === "runtime_seconds")
.map(d => parseFloat(d.raw_value));
const y = d3.scaleLinear()
.domain([0, d3.max(allRuntimes) as number])
.range([height, 0]);
svg.append('g').call(d3.axisLeft(y));
// Tooltip for individual dots
const tooltip = d3.select(boxPlot.value)
.append("div")
.style("position", "absolute")
.style("background", "lightgray")
.style("padding", "5px")
.style("border-radius", "5px")
.style("pointer-events", "none")
.style("visibility", "hidden");
// Render box plots for each tool_id
groupedData.forEach((data, toolId) => {
const runtimeData = data.map(d => parseFloat(d.raw_value));
const center = x(toolId) as number + x.bandwidth() / 2;
const boxWidth = x.bandwidth() / 2;
const q1 = d3.quantile(runtimeData.sort(d3.ascending), 0.25) as number;
const median = d3.quantile(runtimeData.sort(d3.ascending), 0.5) as number;
const q3 = d3.quantile(runtimeData.sort(d3.ascending), 0.75) as number;
const interQuantileRange = q3 - q1;
const min = Math.max(d3.min(runtimeData) as number, q1 - 1.5 * interQuantileRange);
const max = Math.min(d3.max(runtimeData) as number, q3 + 1.5 * interQuantileRange);
// Box
svg.append('rect')
.attr('x', center - boxWidth / 2)
.attr('y', y(q3))
.attr('height', y(q1) - y(q3))
.attr('width', boxWidth)
.attr('stroke', 'black')
.style('fill', '#69b3a2');
// Median, min, max lines
svg.selectAll('line')
.data([min, median, max])
.enter()
.append('line')
.attr('x1', center - boxWidth / 2)
.attr('x2', center + boxWidth / 2)
.attr('y1', d => y(d))
.attr('y2', d => y(d))
.attr('stroke', 'black');
// Vertical lines for min-max
svg.append('line')
.attr('x1', center)
.attr('x2', center)
.attr('y1', y(min))
.attr('y2', y(q1))
.attr('stroke', 'black');
svg.append('line')
.attr('x1', center)
.attr('x2', center)
.attr('y1', y(max))
.attr('y2', y(q3))
.attr('stroke', 'black');
// Add individual data points (dots)
svg.selectAll("circle")
.data(runtimeData)
.enter()
.append("circle")
.attr("cx", center) // Align horizontally to the center of the group
.attr("cy", d => y(d)) // Map the data value to the y-axis
.attr("r", 5) // Radius of the circle
.style("fill", "#ff5722") // Fill color for the points
.attr("stroke", "black")
.on("mouseover", function (event, d) {
tooltip.style("visibility", "visible")
.text(`Value: ${d}`);
d3.select(this).style("fill", "yellow");
})
.on("mousemove", function (event) {
tooltip.style("top", `${event.pageY - 10}px`)
.style("left", `${event.pageX + 10}px`);
})
.on("mouseout", function () {
tooltip.style("visibility", "hidden");
d3.select(this).style("fill", "#ff5722");
});
});
};
// Lifecycle hook to render the plot once the component is mounted
onMounted(() => {
renderBoxPlots();
});
</script>

<style scoped>
svg {
font-family: Arial, sans-serif;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { BCard } from "bootstrap-vue";
import { GalaxyApi, type components } from "@/api";
import { computed, ref, watch } from "vue";
import type JobMetrics from "../JobMetrics/JobMetrics.vue";
import { errorMessageAsString } from "@/utils/simple-error";
import MetricsBoxPlot from "./MetricsBoxPlots.vue";
const props = defineProps({
invocationId: {
type: String,
required: true,
},
});
const jobMetrics = ref<components["schemas"]["JobMetric"][]>()
const fetchError = ref<string>()
async function fetchMetrics() {
const {data, error} = await GalaxyApi().GET("/api/invocations/{invocation_id}/metrics", {
params: {
path: {
invocation_id: props.invocationId
}
}
})
console.log("data", data, error);
if (error) {
fetchError.value = errorMessageAsString(error)
} else {
jobMetrics.value = data
}
}
watch((props), () => fetchMetrics(), { immediate: true })
const wallclock = computed(() => {
return jobMetrics.value?.filter((jobMetric) => jobMetric.name == "runtime_seconds")
})
</script>

<template>
<MetricsBoxPlot v-if="wallclock" :job-data="wallclock"/>
<!--
<div>
{{ jobMetrics }}
</div>
-->
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { isTerminal, jobCount, runningCount } from "./util";
import InvocationReport from "../Workflow/InvocationReport.vue";
import WorkflowInvocationExportOptions from "./WorkflowInvocationExportOptions.vue";
import WorkflowInvocationMetrics from "./WorkflowInvocationMetrics.vue";
import WorkflowInvocationHeader from "./WorkflowInvocationHeader.vue";
import WorkflowInvocationInputOutputTabs from "./WorkflowInvocationInputOutputTabs.vue";
import WorkflowInvocationOverview from "./WorkflowInvocationOverview.vue";
Expand Down Expand Up @@ -185,6 +186,9 @@ function cancelWorkflowSchedulingLocal() {
<LoadingSpan message="Waiting to complete invocation" />
</BAlert>
</BTab>
<BTab title="Metrics">
<WorkflowInvocationMetrics :invocation-id="invocation.id"></WorkflowInvocationMetrics>
</BTab>
</BTabs>
</div>
<BAlert v-else-if="errorMessage" variant="danger" show>
Expand Down
Loading

0 comments on commit 139994a

Please sign in to comment.