mirror of
https://github.com/TwiN/gatus.git
synced 2026-02-15 09:10:07 +00:00
feat(ui): Make tooltips toggleable (#1236)
* feat(results): allow for data points in checks to be "clicked" asdf * feat(ui): resolve merge conflicts feat(dev): put back package.lock * fix(ui): make sure the datapoint stays "fixed" * fix(ui): watch for url changes to make tooltip go away * feat(ui): add compiled app.css and app.js * fix(ui): lengthen the tooltipElement name --------- Co-authored-by: TwiN <twin@linux.com>
This commit is contained in:
@@ -148,7 +148,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tooltip -->
|
<!-- Tooltip -->
|
||||||
<Tooltip :result="tooltip.result" :event="tooltip.event" />
|
<Tooltip :result="tooltip.result" :event="tooltip.event" :isPersistent="tooltipIsPersistent" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -173,6 +173,7 @@ const announcements = ref([])
|
|||||||
const tooltip = ref({})
|
const tooltip = ref({})
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
const isOidcLoading = ref(false)
|
const isOidcLoading = ref(false)
|
||||||
|
const tooltipIsPersistent = ref(false)
|
||||||
let configInterval = null
|
let configInterval = null
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
@@ -209,8 +210,39 @@ const fetchConfig = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showTooltip = (result, event) => {
|
const showTooltip = (result, event, action = 'hover') => {
|
||||||
tooltip.value = { result, event }
|
if (action === 'click') {
|
||||||
|
if (!result) {
|
||||||
|
// Deselecting
|
||||||
|
tooltip.value = {}
|
||||||
|
tooltipIsPersistent.value = false
|
||||||
|
} else {
|
||||||
|
// Selecting new data point
|
||||||
|
tooltip.value = { result, event }
|
||||||
|
tooltipIsPersistent.value = true
|
||||||
|
}
|
||||||
|
} else if (action === 'hover') {
|
||||||
|
// Only update tooltip on hover if not in persistent mode
|
||||||
|
if (!tooltipIsPersistent.value) {
|
||||||
|
tooltip.value = { result, event }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocumentClick = (event) => {
|
||||||
|
// Close persistent tooltip when clicking outside
|
||||||
|
if (tooltipIsPersistent.value) {
|
||||||
|
const tooltipElement = document.getElementById('tooltip')
|
||||||
|
// Check if click is on a data point bar or inside tooltip
|
||||||
|
const clickedDataPoint = event.target.closest('.flex-1.h-6, .flex-1.h-8')
|
||||||
|
|
||||||
|
if (tooltipElement && !tooltipElement.contains(event.target) && !clickedDataPoint) {
|
||||||
|
tooltip.value = {}
|
||||||
|
tooltipIsPersistent.value = false
|
||||||
|
// Emit event to clear selections in child components
|
||||||
|
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch config on mount and set up interval
|
// Fetch config on mount and set up interval
|
||||||
@@ -218,6 +250,8 @@ onMounted(() => {
|
|||||||
fetchConfig()
|
fetchConfig()
|
||||||
// Refresh config every 10 minutes for announcements
|
// Refresh config every 10 minutes for announcements
|
||||||
configInterval = setInterval(fetchConfig, 600000)
|
configInterval = setInterval(fetchConfig, 600000)
|
||||||
|
// Add click listener for closing persistent tooltips
|
||||||
|
document.addEventListener('click', handleDocumentClick)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clean up interval on unmount
|
// Clean up interval on unmount
|
||||||
@@ -226,5 +260,7 @@ onUnmounted(() => {
|
|||||||
clearInterval(configInterval)
|
clearInterval(configInterval)
|
||||||
configInterval = null
|
configInterval = null
|
||||||
}
|
}
|
||||||
|
// Remove click listener
|
||||||
|
document.removeEventListener('click', handleDocumentClick)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -39,10 +39,16 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
:class="[
|
:class="[
|
||||||
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
|
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
|
||||||
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
|
result ? 'cursor-pointer' : '',
|
||||||
|
result ? (
|
||||||
|
result.success
|
||||||
|
? (selectedResultIndex === index ? 'bg-green-700' : 'bg-green-500 hover:bg-green-700')
|
||||||
|
: (selectedResultIndex === index ? 'bg-red-700' : 'bg-red-500 hover:bg-red-700')
|
||||||
|
) : 'bg-gray-200 dark:bg-gray-700'
|
||||||
]"
|
]"
|
||||||
@mouseenter="result && emit('showTooltip', result, $event)"
|
@mouseenter="result && handleMouseEnter(result, $event)"
|
||||||
@mouseleave="result && emit('showTooltip', null, $event)"
|
@mouseleave="result && handleMouseLeave(result, $event)"
|
||||||
|
@click.stop="result && handleClick(result, $event, index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
|
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
|
||||||
@@ -57,7 +63,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
import { computed } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||||
import StatusBadge from '@/components/StatusBadge.vue'
|
import StatusBadge from '@/components/StatusBadge.vue'
|
||||||
@@ -82,6 +88,9 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['showTooltip'])
|
const emit = defineEmits(['showTooltip'])
|
||||||
|
|
||||||
|
// Track selected data point
|
||||||
|
const selectedResultIndex = ref(null)
|
||||||
|
|
||||||
const latestResult = computed(() => {
|
const latestResult = computed(() => {
|
||||||
if (!props.endpoint.results || props.endpoint.results.length === 0) {
|
if (!props.endpoint.results || props.endpoint.results.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -156,4 +165,36 @@ const newestResultTime = computed(() => {
|
|||||||
const navigateToDetails = () => {
|
const navigateToDetails = () => {
|
||||||
router.push(`/endpoints/${props.endpoint.key}`)
|
router.push(`/endpoints/${props.endpoint.key}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMouseEnter = (result, event) => {
|
||||||
|
emit('showTooltip', result, event, 'hover')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = (result, event) => {
|
||||||
|
emit('showTooltip', null, event, 'hover')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (result, event, index) => {
|
||||||
|
// Toggle selection
|
||||||
|
if (selectedResultIndex.value === index) {
|
||||||
|
selectedResultIndex.value = null
|
||||||
|
emit('showTooltip', null, event, 'click')
|
||||||
|
} else {
|
||||||
|
selectedResultIndex.value = index
|
||||||
|
emit('showTooltip', result, event, 'click')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for clear selection event
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
selectedResultIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('clear-data-point-selection', handleClearSelection)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('clear-data-point-selection', handleClearSelection)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
id="tooltip"
|
id="tooltip"
|
||||||
ref="tooltip"
|
ref="tooltip"
|
||||||
:class="[
|
:class="[
|
||||||
'fixed z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
|
'absolute z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
|
||||||
'bg-popover text-popover-foreground border-border',
|
'bg-popover text-popover-foreground border-border',
|
||||||
hidden ? 'invisible opacity-0' : 'visible opacity-100'
|
hidden ? 'invisible opacity-0' : 'visible opacity-100'
|
||||||
]"
|
]"
|
||||||
:style="`top: ${top}px; left: ${left}px;`"
|
:style="`top: ${top}px; left: ${left}px;`"
|
||||||
>
|
>
|
||||||
<div v-if="result" class="space-y-2">
|
<div v-if="result" class="space-y-2">
|
||||||
@@ -96,9 +96,12 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
import { ref, watch, nextTick, computed } from 'vue'
|
import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { prettifyTimestamp } from '@/utils/time'
|
import { prettifyTimestamp } from '@/utils/time'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
event: {
|
event: {
|
||||||
type: [Event, Object],
|
type: [Event, Object],
|
||||||
@@ -107,6 +110,10 @@ const props = defineProps({
|
|||||||
result: {
|
result: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
isPersistent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -115,6 +122,7 @@ const hidden = ref(true)
|
|||||||
const top = ref(0)
|
const top = ref(0)
|
||||||
const left = ref(0)
|
const left = ref(0)
|
||||||
const tooltip = ref(null)
|
const tooltip = ref(null)
|
||||||
|
const targetElement = ref(null)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const isSuiteResult = computed(() => {
|
const isSuiteResult = computed(() => {
|
||||||
@@ -133,75 +141,109 @@ const successCount = computed(() => {
|
|||||||
|
|
||||||
// Methods are imported from utils/time
|
// Methods are imported from utils/time
|
||||||
|
|
||||||
|
// Update tooltip position based on target element's current position
|
||||||
|
const updatePosition = async () => {
|
||||||
|
if (!targetElement.value || !tooltip.value || hidden.value) return
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const targetRect = targetElement.value.getBoundingClientRect()
|
||||||
|
const tooltipRect = tooltip.value.getBoundingClientRect()
|
||||||
|
|
||||||
|
// For absolute positioning, we need to add scroll offsets
|
||||||
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft
|
||||||
|
|
||||||
|
// Default position: below the target (viewport coords + scroll offset)
|
||||||
|
let newTop = targetRect.bottom + scrollTop + 8
|
||||||
|
let newLeft = targetRect.left + scrollLeft
|
||||||
|
|
||||||
|
// Check if tooltip would overflow the viewport bottom
|
||||||
|
const spaceBelow = window.innerHeight - targetRect.bottom
|
||||||
|
const spaceAbove = targetRect.top
|
||||||
|
|
||||||
|
if (spaceBelow < tooltipRect.height + 20) {
|
||||||
|
// Not enough space below, try above
|
||||||
|
if (spaceAbove > tooltipRect.height + 20) {
|
||||||
|
// Position above
|
||||||
|
newTop = targetRect.top + scrollTop - tooltipRect.height - 8
|
||||||
|
} else {
|
||||||
|
// Not enough space above either, position at the best spot
|
||||||
|
if (spaceAbove > spaceBelow) {
|
||||||
|
// More space above
|
||||||
|
newTop = scrollTop + 10
|
||||||
|
} else {
|
||||||
|
// More space below or equal, keep below but adjust
|
||||||
|
newTop = scrollTop + window.innerHeight - tooltipRect.height - 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust horizontal position if tooltip would overflow right edge
|
||||||
|
const spaceRight = window.innerWidth - targetRect.left
|
||||||
|
if (spaceRight < tooltipRect.width + 20) {
|
||||||
|
// Align right edge of tooltip with right edge of target
|
||||||
|
newLeft = targetRect.right + scrollLeft - tooltipRect.width
|
||||||
|
// Make sure it doesn't go off the left edge
|
||||||
|
if (newLeft < scrollLeft + 10) {
|
||||||
|
newLeft = scrollLeft + 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
top.value = Math.round(newTop)
|
||||||
|
left.value = Math.round(newLeft)
|
||||||
|
}
|
||||||
|
|
||||||
const reposition = async () => {
|
const reposition = async () => {
|
||||||
if (!props.event || !props.event.type) return
|
if (!props.event || !props.event.type) return
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
if (props.event.type === 'mouseenter' && tooltip.value) {
|
if ((props.event.type === 'mouseenter' || props.event.type === 'click') && tooltip.value) {
|
||||||
const target = props.event.target
|
const target = props.event.target
|
||||||
const targetRect = target.getBoundingClientRect()
|
// Store the target element for scroll updates
|
||||||
|
targetElement.value = target
|
||||||
// First, position tooltip to get its dimensions
|
|
||||||
|
// First, make tooltip visible to get its dimensions
|
||||||
hidden.value = false
|
hidden.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const tooltipRect = tooltip.value.getBoundingClientRect()
|
// Update position
|
||||||
|
await updatePosition()
|
||||||
// Since tooltip uses position: fixed, we work with viewport coordinates
|
|
||||||
// getBoundingClientRect() already gives us viewport-relative positions
|
|
||||||
|
|
||||||
// Default position: below the target
|
|
||||||
let newTop = targetRect.bottom + 8
|
|
||||||
let newLeft = targetRect.left
|
|
||||||
|
|
||||||
// Check if tooltip would overflow the viewport bottom
|
|
||||||
const spaceBelow = window.innerHeight - targetRect.bottom
|
|
||||||
const spaceAbove = targetRect.top
|
|
||||||
|
|
||||||
if (spaceBelow < tooltipRect.height + 20) {
|
|
||||||
// Not enough space below, try above
|
|
||||||
if (spaceAbove > tooltipRect.height + 20) {
|
|
||||||
// Position above
|
|
||||||
newTop = targetRect.top - tooltipRect.height - 8
|
|
||||||
} else {
|
|
||||||
// Not enough space above either, position at the best spot
|
|
||||||
if (spaceAbove > spaceBelow) {
|
|
||||||
// More space above
|
|
||||||
newTop = 10
|
|
||||||
} else {
|
|
||||||
// More space below or equal, keep below but adjust
|
|
||||||
newTop = window.innerHeight - tooltipRect.height - 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust horizontal position if tooltip would overflow right edge
|
|
||||||
const spaceRight = window.innerWidth - targetRect.left
|
|
||||||
if (spaceRight < tooltipRect.width + 20) {
|
|
||||||
// Align right edge of tooltip with right edge of target
|
|
||||||
newLeft = targetRect.right - tooltipRect.width
|
|
||||||
// Make sure it doesn't go off the left edge
|
|
||||||
if (newLeft < 10) {
|
|
||||||
newLeft = 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
top.value = Math.round(newTop)
|
|
||||||
left.value = Math.round(newLeft)
|
|
||||||
} else if (props.event.type === 'mouseleave') {
|
} else if (props.event.type === 'mouseleave') {
|
||||||
hidden.value = true
|
// Only hide on mouseleave if not in persistent mode
|
||||||
|
if (!props.isPersistent) {
|
||||||
|
hidden.value = true
|
||||||
|
targetElement.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle resize events (still needed for viewport size changes)
|
||||||
|
const handleResize = () => {
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle hooks
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
// Watchers
|
// Watchers
|
||||||
watch(() => props.event, (newEvent) => {
|
watch(() => props.event, (newEvent) => {
|
||||||
if (newEvent && newEvent.type) {
|
if (newEvent && newEvent.type) {
|
||||||
if (newEvent.type === 'mouseenter') {
|
if (newEvent.type === 'mouseenter' || newEvent.type === 'click') {
|
||||||
hidden.value = false
|
hidden.value = false
|
||||||
nextTick(() => reposition())
|
nextTick(() => reposition())
|
||||||
} else if (newEvent.type === 'mouseleave') {
|
} else if (newEvent.type === 'mouseleave') {
|
||||||
hidden.value = true
|
// Only hide on mouseleave if not in persistent mode
|
||||||
|
if (!props.isPersistent) {
|
||||||
|
hidden.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
@@ -211,4 +253,22 @@ watch(() => props.result, () => {
|
|||||||
nextTick(() => reposition())
|
nextTick(() => reposition())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Watch for persistent state changes and result changes
|
||||||
|
watch(() => [props.isPersistent, props.result], ([isPersistent, result]) => {
|
||||||
|
if (!isPersistent && !result) {
|
||||||
|
// Hide tooltip when both persistent mode is off and no result
|
||||||
|
hidden.value = true
|
||||||
|
} else if (result && (isPersistent || props.event?.type === 'mouseenter')) {
|
||||||
|
// Show tooltip when there's a result and either persistent or hovering
|
||||||
|
hidden.value = false
|
||||||
|
nextTick(() => reposition())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for route changes and hide tooltip
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
hidden.value = true
|
||||||
|
targetElement.value = null
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -369,8 +369,8 @@ const changePage = (page) => {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showTooltip = (result, event) => {
|
const showTooltip = (result, event, action = 'hover') => {
|
||||||
emit('showTooltip', result, event)
|
emit('showTooltip', result, event, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prettifyTimestamp = (timestamp) => {
|
const prettifyTimestamp = (timestamp) => {
|
||||||
|
|||||||
@@ -471,8 +471,8 @@ const toggleShowAverageResponseTime = () => {
|
|||||||
showAverageResponseTime.value = !showAverageResponseTime.value
|
showAverageResponseTime.value = !showAverageResponseTime.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const showTooltip = (result, event) => {
|
const showTooltip = (result, event, action = 'hover') => {
|
||||||
emit('showTooltip', result, event)
|
emit('showTooltip', result, event, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateUnhealthyCount = (endpoints) => {
|
const calculateUnhealthyCount = (endpoints) => {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user