fix(ui): Modernize response time chart (#1373)

This commit is contained in:
TwiN
2025-10-29 14:15:59 -04:00
committed by GitHub
parent beb9a2f3d9
commit e2f06e9ede
13 changed files with 656 additions and 536 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -44,13 +44,6 @@
:key="date"
class="relative"
>
<!-- Vertical line from date to last icon -->
<div
v-if="group.length > 0"
class="absolute left-3 w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
:style="getTimelineHeight(group)"
></div>
<!-- Date Header -->
<div class="flex items-center gap-3 mb-2 relative">
<div class="relative z-10 bg-white dark:bg-gray-800 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-600">
@@ -69,18 +62,37 @@
class="relative"
>
<!-- Timeline Icon -->
<div
<div
:class="[
'absolute -left-[26px] top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
'absolute -left-[26px] w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
index === group.length - 1 ? 'top-3' : 'top-1/2 -translate-y-1/2',
getTypeClasses(announcement.type).border
]"
>
<component
:is="getTypeIcon(announcement.type)"
<component
:is="getTypeIcon(announcement.type)"
:class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]"
/>
</div>
<!-- Vertical line segment connecting upward from first icon to date -->
<div
v-if="index === 0"
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
style="left: -16px; top: -2.5rem; height: calc(50% + 2.5rem);"
></div>
<!-- Vertical line segment connecting downward to next icon -->
<div
v-if="index < group.length - 1"
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
:style="{
left: '-16px',
top: '50%',
height: index === group.length - 2 ? 'calc(50% + 1.25rem)' : 'calc(50% + 2rem)'
}"
></div>
<!-- Announcement Card -->
<div
:class="[
@@ -220,14 +232,6 @@ const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none
}
const getTimelineHeight = (group) => {
const height = group.length === 1 ? '2rem' : `${2 + (group.length - 1) * 3.5}rem`
return {
top: '1.5rem',
height
}
}
const formatDate = (dateString) => {
const date = new Date(dateString)
const today = new Date()

View File

@@ -0,0 +1,294 @@
<template>
<div class="relative w-full" style="height: 300px;">
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background/50">
<Loading />
</div>
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center text-muted-foreground">
{{ error }}
</div>
<Line v-else :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale } from 'chart.js'
import annotationPlugin from 'chartjs-plugin-annotation'
import 'chartjs-adapter-date-fns'
import { generatePrettyTimeDifference } from '@/utils/time'
import Loading from './Loading.vue'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale, annotationPlugin)
const props = defineProps({
endpointKey: {
type: String,
required: true
},
duration: {
type: String,
required: true,
validator: (value) => ['24h', '7d', '30d'].includes(value)
},
serverUrl: {
type: String,
default: '..'
},
events: {
type: Array,
default: () => []
}
})
const loading = ref(true)
const error = ref(null)
const timestamps = ref([])
const values = ref([])
const isDark = ref(document.documentElement.classList.contains('dark'))
const hoveredEventIndex = ref(null)
// Helper function to get color for unhealthy events
const getEventColor = () => {
// Only UNHEALTHY events are displayed on the chart
return 'rgba(239, 68, 68, 0.8)' // Red
}
// Filter events based on selected duration and calculate durations
const filteredEvents = computed(() => {
if (!props.events || props.events.length === 0) {
return []
}
const now = new Date()
let fromTime
switch (props.duration) {
case '24h':
fromTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case '7d':
fromTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case '30d':
fromTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
default:
return []
}
// Only include UNHEALTHY events and calculate their duration
const unhealthyEvents = []
for (let i = 0; i < props.events.length; i++) {
const event = props.events[i]
if (event.type !== 'UNHEALTHY') continue
const eventTime = new Date(event.timestamp)
if (eventTime < fromTime || eventTime > now) continue
// Find the next event to calculate duration
let duration = null
let isOngoing = false
if (i + 1 < props.events.length) {
const nextEvent = props.events[i + 1]
duration = generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
} else {
// Still ongoing - calculate duration from event time to now
duration = generatePrettyTimeDifference(now, event.timestamp)
isOngoing = true
}
unhealthyEvents.push({
...event,
duration,
isOngoing
})
}
return unhealthyEvents
})
const chartData = computed(() => {
if (timestamps.value.length === 0) {
return {
labels: [],
datasets: []
}
}
const labels = timestamps.value.map(ts => new Date(ts))
return {
labels,
datasets: [{
label: 'Response Time (ms)',
data: values.value,
borderColor: isDark.value ? 'rgb(96, 165, 250)' : 'rgb(59, 130, 246)',
backgroundColor: isDark.value ? 'rgba(96, 165, 250, 0.1)' : 'rgba(59, 130, 246, 0.1)',
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 4,
tension: 0.1,
fill: true
}]
}
})
const chartOptions = computed(() => {
// Include hoveredEventIndex in dependency tracking
// eslint-disable-next-line no-unused-vars
const _ = hoveredEventIndex.value
// Calculate max Y value for positioning annotations
const maxY = values.value.length > 0 ? Math.max(...values.value) : 0
const midY = maxY / 2
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark.value ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
titleColor: isDark.value ? '#f9fafb' : '#111827',
bodyColor: isDark.value ? '#d1d5db' : '#374151',
borderColor: isDark.value ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
title: (tooltipItems) => {
if (tooltipItems.length > 0) {
const date = new Date(tooltipItems[0].parsed.x)
return date.toLocaleString()
}
return ''
},
label: (context) => {
const value = context.parsed.y
return `${value}ms`
}
}
},
annotation: {
annotations: filteredEvents.value.reduce((acc, event, index) => {
// Find closest data point to determine annotation position
const eventTimestamp = new Date(event.timestamp).getTime()
let closestValue = 0
if (timestamps.value.length > 0 && values.value.length > 0) {
const closestIndex = timestamps.value.reduce((closest, ts, idx) => {
const tsTime = new Date(ts).getTime()
const currentDistance = Math.abs(tsTime - eventTimestamp)
const closestDistance = Math.abs(new Date(timestamps.value[closest]).getTime() - eventTimestamp)
return currentDistance < closestDistance ? idx : closest
}, 0)
closestValue = values.value[closestIndex]
}
// Position annotation at bottom if data point is in lower half, at top if in upper half
const position = closestValue <= midY ? 'end' : 'start'
acc[`event-${index}`] = {
type: 'line',
xMin: new Date(event.timestamp),
xMax: new Date(event.timestamp),
borderColor: getEventColor(),
borderWidth: 1,
borderDash: [5, 5],
enter() {
hoveredEventIndex.value = index
},
leave() {
hoveredEventIndex.value = null
},
label: {
display: () => hoveredEventIndex.value === index,
content: [event.isOngoing ? `Status: ONGOING` : `Status: RESOLVED`, `Unhealthy for ${event.duration}`, `Started at ${new Date(event.timestamp).toLocaleString()}`],
backgroundColor: getEventColor(),
color: '#ffffff',
font: {
size: 11
},
padding: 6,
position
}
}
return acc
}, {})
}
},
scales: {
x: {
type: 'time',
time: {
unit: props.duration === '24h' ? 'hour' : props.duration === '7d' ? 'day' : 'day',
displayFormats: {
hour: 'MMM d, ha',
day: 'MMM d'
}
},
grid: {
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
drawBorder: false
},
ticks: {
color: isDark.value ? '#9ca3af' : '#6b7280',
maxRotation: 0,
autoSkipPadding: 20
}
},
y: {
beginAtZero: true,
grid: {
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
drawBorder: false
},
ticks: {
color: isDark.value ? '#9ca3af' : '#6b7280',
callback: (value) => `${value}ms`
}
}
}
}
})
const fetchData = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(`${props.serverUrl}/api/v1/endpoints/${props.endpointKey}/response-times/${props.duration}/history`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
timestamps.value = data.timestamps || []
values.value = data.values || []
} else {
error.value = 'Failed to load chart data'
console.error('[ResponseTimeChart] Error:', await response.text())
}
} catch (err) {
error.value = 'Failed to load chart data'
console.error('[ResponseTimeChart] Error:', err)
} finally {
loading.value = false
}
}
watch(() => props.duration, () => {
fetchData()
})
onMounted(() => {
fetchData()
const observer = new MutationObserver(() => {
isDark.value = document.documentElement.classList.contains('dark')
})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
onUnmounted(() => observer.disconnect())
})
</script>

View File

@@ -31,8 +31,28 @@ export const generatePrettyTimeAgo = (timestamp) => {
* @returns {string} Time difference string
*/
export const generatePrettyTimeDifference = (start, end) => {
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
return minutes + (minutes === 1 ? ' minute' : ' minutes');
const ms = new Date(start) - new Date(end)
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
const hoursText = hours + (hours === 1 ? ' hour' : ' hours')
if (remainingMinutes > 0) {
return hoursText + ' ' + remainingMinutes + (remainingMinutes === 1 ? ' minute' : ' minutes')
}
return hoursText
} else if (minutes > 0) {
const remainingSeconds = seconds % 60
const minutesText = minutes + (minutes === 1 ? ' minute' : ' minutes')
if (remainingSeconds > 0) {
return minutesText + ' ' + remainingSeconds + (remainingSeconds === 1 ? ' second' : ' seconds')
}
return minutesText
} else {
return seconds + (seconds === 1 ? ' second' : ' seconds')
}
}
/**

View File

@@ -117,7 +117,13 @@
</div>
</CardHeader>
<CardContent>
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="Response time chart" class="w-full" />
<ResponseTimeChart
v-if="endpointStatus && endpointStatus.key"
:endpointKey="endpointStatus.key"
:duration="selectedChartDuration"
:serverUrl="serverUrl"
:events="endpointStatus.events || []"
/>
</CardContent>
</Card>
@@ -206,6 +212,7 @@ import EndpointCard from '@/components/EndpointCard.vue'
import Settings from '@/components/Settings.vue'
import Pagination from '@/components/Pagination.vue'
import Loading from '@/components/Loading.vue'
import ResponseTimeChart from '@/components/ResponseTimeChart.vue'
import { SERVER_URL } from '@/main.js'
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
@@ -389,10 +396,6 @@ const generateResponseTimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
}
const generateResponseTimeChartImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/chart.svg`
}
onMounted(() => {
fetchData()
})