Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Frontend/src/resources/EndpointThroughputSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ interface EndpointThroughputSummary {
is_known_endpoint: boolean;
user_indicator: string;
max_daily_throughput: number;
monthly_throughput?: MonthlyThroughput[];
max_monthly_throughput?: number;
}

export interface MonthlyThroughput {
month: string;
throughput: number;
}

export type { EndpointThroughputSummary as default };
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ describe("DetectedListView tests", () => {
//let orderedNames = unsortedNames.sort((a, b) => a.localeCompare(b));
let idx = 0;
for (const row of within(table).getAllByRole("row").slice(1)) {
expect(within(row).getByRole("cell", { name: "name" }).textContent).toBe(lexicallySortedNames[idx++]);
expect(within(row).getByRole("cell", { name: "name" }).textContent.trim()).toBe(lexicallySortedNames[idx++]);
}

await user.click(screen.getByRole("button", { name: /Sort by/i }));
Expand All @@ -264,7 +264,7 @@ describe("DetectedListView tests", () => {
const reverseLexicallySortedNames = lexicallySortedNames.reverse();
idx = 0;
for (const row of within(table).getAllByRole("row").slice(1)) {
expect(within(row).getByRole("cell", { name: "name" }).textContent).toBe(reverseLexicallySortedNames[idx++]);
expect(within(row).getByRole("cell", { name: "name" }).textContent.trim()).toBe(reverseLexicallySortedNames[idx++]);
}
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useHiddenFeature } from "./useHiddenFeature";
import FAIcon from "@/components/FAIcon.vue";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { useLicenseStore } from "@/stores/LicenseStore";
import InlineThroughputGraph from "./InlineThroughputGraph.vue";

enum NameFilterType {
beginsWith = "Begins with",
Expand Down Expand Up @@ -246,7 +247,10 @@ async function save() {
</tr>
<tr v-for="row in filteredData" :key="row.name">
<td class="col" aria-label="name">
{{ row.name }}
<div class="endpoint-name">
<InlineThroughputGraph v-if="row.monthly_throughput" :data="row.monthly_throughput" />
{{ row.name }}
</div>
</td>
<td v-if="showMonthly" class="col text-end formatThroughputColumn" style="width: 250px" aria-label="maximum usage throughput">{{ row.max_monthly_throughput ? row.max_monthly_throughput.toLocaleString() : "0" }}</td>
<td v-else class="col text-end formatThroughputColumn" style="width: 250px" aria-label="maximum usage throughput">{{ row.max_daily_throughput.toLocaleString() }}</td>
Expand Down Expand Up @@ -287,4 +291,9 @@ async function save() {
border-radius: 3px;
padding: 5px;
}
.endpoint-name {
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from "vue";
import ThroughputGraph from "./ThroughputGraph.vue";
import type { MonthlyThroughput } from "@/resources/EndpointThroughputSummary.ts";
import FAIcon from "@/components/FAIcon.vue";
import { faLineChart } from "@fortawesome/free-solid-svg-icons";

defineProps<{ data: MonthlyThroughput[] }>();

const showContent = ref(false);
</script>

<template>
<div class="graph-container" :class="{ showContent }">
<div class="icon" @click="showContent = !showContent">
<FAIcon :icon="faLineChart" />
</div>
<ThroughputGraph class="graph" :data="data" />
</div>
</template>

<style scoped>
.graph-container {
position: relative;
--pips: 0;
}

.graph-container .icon {
cursor: pointer;
outline: none;
}

.graph-container.showContent .icon {
color: var(--sp-blue);
}

.icon svg {
pointer-events: none;
}

.graph {
display: none;
position: absolute;
top: 1em;
left: 20%;
height: 7.5rem;
box-shadow: 1px 2px 3px hsl(var(--shadow-color));
}

.graph-container:hover .graph,
.graph-container.showContent .graph,
.graph-container:has(svg:focus) .graph {
display: flex;
z-index: 1;
}
</style>
103 changes: 103 additions & 0 deletions src/Frontend/src/views/throughputreport/endpoints/ThroughputGraph.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<script setup lang="ts">
import type { MonthlyThroughput } from "@/resources/EndpointThroughputSummary";
import { computed } from "vue";

const props = defineProps<{ data: MonthlyThroughput[] }>();

const date = new Date();
date.setMonth(date.getMonth() - 13);
const reportPeriod = Array.from({ length: 13 }).map(() => {
date.setMonth(date.getMonth() + 1);
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}`;
});

const maxValue = computed(() => Math.max(...[...props.data.map(({ throughput }) => throughput), 1]));
const maxValueText = computed(() => {
if (maxValue.value >= 1_000_000) return `${(Math.round((maxValue.value * 10) / 1_000_000) / 10 + 0.1).toFixed(1)}M`;
if (maxValue.value >= 10_000) return `${(Math.round((maxValue.value * 100) / 1_000) / 100 + 0.01).toFixed(2)}k`;
return maxValue.value;
});
const lines = computed(() =>
props.data
.slice(0, -1)
.map(({ month, throughput }, index) => {
const x1 = reportPeriod.indexOf(month);
const y1 = 1 - throughput / maxValue.value;
const { month: nextMonth, throughput: nextValue } = props.data[index + 1];
const x2 = reportPeriod.indexOf(nextMonth);
if (x2 !== x1 + 1) return null;
const y2 = 1 - nextValue / maxValue.value;
const isPartMonth = x1 === 0 || x2 === reportPeriod.length - 1;
return { x1, y1, x2, y2, isPartMonth };
})
.filter((x) => x != null)
);
const dots = computed(() =>
props.data.map(({ month, throughput }) => ({
pip: reportPeriod.indexOf(month),
value: throughput,
}))
);
</script>

<template>
<div class="graph" :style="{ '--pips': reportPeriod.length * 2 }">
<div class="graph-content">
<svg class="graph-content" :viewBox="`-0.2 -0.03 ${reportPeriod.length * 2 + 0.1} 1.07`" preserveAspectRatio="none">
<line v-for="({ x1, y1, x2, y2, isPartMonth }, i) in lines" :key="i" :x1="x1 * 2" :y1="y1" :x2="x2 * 2" :y2="y2" stroke="var(--bs-primary)" stroke-width="1" :stroke-dasharray="isPartMonth ? 4 : 0" vector-effect="non-scaling-stroke" />
<line v-for="(_, pip) in reportPeriod" :key="pip" :x1="pip * 2" y1="1.07" :x2="pip * 2" y2="1" stroke="var(--bs-body-color)" stroke-width="1" vector-effect="non-scaling-stroke" />
<g v-for="{ pip, value } in dots" :key="pip">
<title>{{ value.toLocaleString() }}</title>
<path r="1" :cx="pip * 2" :cy="1 - value / maxValue" :d="`M ${pip * 2} ${1 - value / maxValue} l 0.0001 0`" vector-effect="non-scaling-stroke" stroke-width="5" stroke-linecap="round" :stroke="value === 0 ? 'red' : 'var(--bs-primary)'" />
</g>
</svg>
<div class="y-axis">
<span v-for="pip in reportPeriod" :key="pip">{{ pip }}</span>
</div>
</div>
<div class="scale">
<span>{{ maxValueText }}</span>
<span>0</span>
</div>
</div>
</template>

<style scoped>
.graph {
display: flex;
width: calc(var(--pips) * 1rem + 2rem);
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 5px;
padding: 3px;
padding-left: 1rem;
}

.graph-content {
display: flex;
flex-direction: column;
flex: 1;
}

.graph-content .y-axis {
display: flex;
justify-content: space-between;
font-size: 0.5rem;
height: 2rem;
}

.graph-content .y-axis span {
transform-origin: 100% 80%;
transform: translateX(-0.4rem) translateY(-0.5rem) rotate(-45deg);
}

.scale {
width: 2rem;
margin-left: -1rem;
height: calc(100% - 2rem);
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 0.5rem;
}
</style>