1
0
mirror of https://github.com/TwiN/gatus.git synced 2026-02-08 16:54:17 +00:00

feat(ui): New status page UI (#1198)

* feat(ui): New status page UI

* docs: Rename labels to extra-labels

* Fix domain expiration test

* feat(ui): Add ui.default-sort-by and ui.default-filter-by

* Change ui.header default value to Gatus

* Re-use EndpointCard in Details.vue as well to avoid duplicate code

* Fix flaky metrics test

* Add subtle green color to "Gatus"

* Remove duplicate title (tooltip is sufficient, no need for title on top of that)

* Fix collapsed group user preferences

* Update status page screenshots
This commit is contained in:
TwiN
2025-08-14 09:15:34 -04:00
committed by GitHub
parent 8d63462fcd
commit 440b732c71
54 changed files with 4251 additions and 2226 deletions

View File

@@ -1,186 +0,0 @@
<template>
<div class='endpoint px-3 py-3 border-l border-r border-t rounded-none hover:bg-gray-100 dark:hover:bg-gray-700 dark:border-gray-500' v-if="data">
<div class='flex flex-wrap mb-2'>
<div class='w-3/4'>
<router-link :to="generatePath()" class="font-bold hover:text-blue-800 hover:underline dark:hover:text-blue-400" title="View detailed endpoint health">
{{ data.name }}
</router-link>
<span v-if="data.results && data.results.length && data.results[data.results.length - 1].hostname" class='text-gray-500 font-light'> | {{ data.results[data.results.length - 1].hostname }}</span>
</div>
<div class='w-1/4 text-right'>
<span class='font-light overflow-x-hidden cursor-pointer select-none hover:text-gray-500' v-if="data.results && data.results.length" @click="toggleShowAverageResponseTime" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">
<slot v-if="showAverageResponseTime">
~{{ averageResponseTime }}ms
</slot>
<slot v-else>
{{ (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + '-' + maxResponseTime)) }}ms
</slot>
</span>
<!-- <span class="text-sm font-bold cursor-pointer">-->
<!-- -->
<!-- </span>-->
</div>
</div>
<div>
<div class='status-over-time flex flex-row'>
<slot v-if="data.results && data.results.length">
<slot v-if="data.results.length < maximumNumberOfResults">
<span v-for="filler in maximumNumberOfResults - data.results.length" :key="filler" class="status rounded border border-dashed border-gray-400">&nbsp;</span>
</slot>
<slot v-for="result in data.results" :key="result">
<span v-if="result.success" class="status status-success rounded bg-success" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
<span v-else class="status status-failure rounded bg-red-600" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)"></span>
</slot>
</slot>
<slot v-else>
<span v-for="filler in maximumNumberOfResults" :key="filler" class="status rounded border border-dashed border-gray-400">&nbsp;</span>
</slot>
</div>
</div>
<div class='flex flex-wrap status-time-ago'>
<slot v-if="data.results && data.results.length">
<div class='w-1/2'>
{{ generatePrettyTimeAgo(data.results[0].timestamp) }}
</div>
<div class='w-1/2 text-right'>
{{ generatePrettyTimeAgo(data.results[data.results.length - 1].timestamp) }}
</div>
</slot>
<slot v-else>
<div class='w-1/2'>
&nbsp;
</div>
</slot>
</div>
</div>
</template>
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Endpoint',
props: {
maximumNumberOfResults: Number,
data: Object,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
mixins: [helper],
methods: {
updateMinAndMaxResponseTimes() {
let minResponseTime = null;
let maxResponseTime = null;
let totalResponseTime = 0;
for (let i in this.data.results) {
const responseTime = parseInt((this.data.results[i].duration/1000000).toFixed(0));
totalResponseTime += responseTime;
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
}
if (this.minResponseTime !== minResponseTime) {
this.minResponseTime = minResponseTime;
}
if (this.maxResponseTime !== maxResponseTime) {
this.maxResponseTime = maxResponseTime;
}
if (this.data.results && this.data.results.length) {
this.averageResponseTime = (totalResponseTime/this.data.results.length).toFixed(0);
}
},
generatePath() {
if (!this.data) {
return '/';
}
return `/endpoints/${this.data.key}`;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
data: function () {
this.updateMinAndMaxResponseTimes();
}
},
created() {
this.updateMinAndMaxResponseTimes()
},
data() {
return {
minResponseTime: 0,
maxResponseTime: 0,
averageResponseTime: 0
}
}
}
</script>
<style>
.endpoint:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.endpoint:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-width: 3px;
}
.status-over-time {
overflow: auto;
}
.status-over-time > span:not(:first-child) {
margin-left: 2px;
}
.status {
cursor: pointer;
transition: all 500ms ease-in-out;
overflow-x: hidden;
color: white;
width: 5%;
font-size: 75%;
font-weight: 700;
text-align: center;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
color: black;
}
.status-time-ago {
color: #6a737d;
opacity: 0.5;
margin-top: 5px;
}
.status.status-success::after {
content: "✓";
}
.status.status-failure::after {
content: "X";
}
@media screen and (max-width: 600px) {
.status.status-success::after,
.status.status-failure::after {
content: " ";
white-space: pre;
}
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<Card class="endpoint hover:shadow-lg transition-shadow cursor-pointer h-full flex flex-col">
<CardHeader class="endpoint-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0">
<div class="flex items-start justify-between gap-2 sm:gap-3">
<div class="flex-1 min-w-0 overflow-hidden">
<CardTitle class="text-base sm:text-lg truncate">
<span
class="hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate"
@click="navigateToDetails"
@keydown.enter="navigateToDetails"
:title="endpoint.name"
role="link"
tabindex="0"
:aria-label="`View details for ${endpoint.name}`">
{{ endpoint.name }}
</span>
</CardTitle>
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<span v-if="endpoint.group" class="truncate" :title="endpoint.group">{{ endpoint.group }}</span>
<span v-if="endpoint.group && hostname"></span>
<span v-if="hostname" class="truncate" :title="hostname">{{ hostname }}</span>
</div>
</div>
<div class="flex-shrink-0 ml-2">
<StatusBadge :status="currentStatus" />
</div>
</div>
</CardHeader>
<CardContent class="endpoint-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2">
<div class="space-y-2">
<div>
<div class="flex items-center justify-between mb-1">
<div class="flex-1"></div>
<p class="text-xs text-muted-foreground" :title="showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'">{{ formattedResponseTime }}</p>
</div>
<div class="flex gap-0.5">
<div
v-for="(result, index) in displayResults"
:key="index"
:class="[
'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'
]"
@mouseenter="result && emit('showTooltip', result, $event)"
@mouseleave="result && emit('showTooltip', null, $event)"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span>{{ oldestResultTime }}</span>
<span>{{ newestResultTime }}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
<script setup>
/* eslint-disable no-undef */
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
import { helper } from '@/mixins/helper'
const router = useRouter()
const props = defineProps({
endpoint: {
type: Object,
required: true
},
maxResults: {
type: Number,
default: 50
},
showAverageResponseTime: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['showTooltip'])
const latestResult = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return null
}
return props.endpoint.results[props.endpoint.results.length - 1]
})
const currentStatus = computed(() => {
if (!latestResult.value) return 'unknown'
return latestResult.value.success ? 'healthy' : 'unhealthy'
})
const hostname = computed(() => {
return latestResult.value?.hostname || null
})
const displayResults = computed(() => {
const results = [...(props.endpoint.results || [])]
while (results.length < props.maxResults) {
results.unshift(null)
}
return results.slice(-props.maxResults)
})
const formattedResponseTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return 'N/A'
}
let total = 0
let count = 0
let min = Infinity
let max = 0
for (const result of props.endpoint.results) {
if (result.duration) {
const durationMs = result.duration / 1000000
total += durationMs
count++
min = Math.min(min, durationMs)
max = Math.max(max, durationMs)
}
}
if (count === 0) return 'N/A'
if (props.showAverageResponseTime) {
const avgMs = Math.round(total / count)
return `~${avgMs}ms`
} else {
// Show min-max range
const minMs = Math.round(min)
const maxMs = Math.round(max)
// If min and max are the same, show single value
if (minMs === maxMs) {
return `${minMs}ms`
}
return `${minMs}-${maxMs}ms`
}
})
const oldestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
})
const newestResultTime = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[props.endpoint.results.length - 1].timestamp)
})
const navigateToDetails = () => {
router.push(`/endpoints/${props.endpoint.key}`)
}
</script>

View File

@@ -1,99 +0,0 @@
<template>
<div :class="endpoints.length === 0 ? 'mt-3' : 'mt-4'">
<slot v-if="name !== 'undefined'">
<div class="endpoint-group pt-2 border dark:bg-gray-800 dark:border-gray-500" @click="toggleGroup">
<h5 class="font-mono text-gray-400 text-xl font-medium pb-2 px-3 dark:text-gray-200 dark:hover:text-gray-500 dark:border-gray-500">
<span class="endpoint-group-arrow mr-2">
{{ collapsed ? '&#9660;' : '&#9650;' }}
</span>
{{ name }}
<span v-if="unhealthyCount" class="rounded-xl bg-red-600 text-white px-2 font-bold leading-6 float-right h-6 text-center hover:scale-110 text-sm" title="Partial Outage">{{unhealthyCount}}</span>
<span v-else class="float-right text-green-600 w-7 hover:scale-110" title="Operational">
<CheckCircleIcon />
</span>
</h5>
</div>
</slot>
<div v-if="!collapsed" :class="name === 'undefined' ? '' : 'endpoint-group-content'">
<slot v-for="(endpoint, idx) in endpoints" :key="idx">
<Endpoint
:data="endpoint"
:maximumNumberOfResults="20"
@showTooltip="showTooltip"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime"
/>
</slot>
</div>
</div>
</template>
<script>
import Endpoint from './Endpoint.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid'
export default {
name: 'EndpointGroup',
components: {
Endpoint,
CheckCircleIcon
},
props: {
name: String,
endpoints: Array,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
healthCheck() {
let unhealthyCount = 0
if (this.endpoints) {
for (let i in this.endpoints) {
if (this.endpoints[i].results && this.endpoints[i].results.length > 0) {
if (!this.endpoints[i].results[this.endpoints[i].results.length-1].success) {
unhealthyCount++
}
}
}
}
this.unhealthyCount = unhealthyCount;
},
toggleGroup() {
this.collapsed = !this.collapsed;
localStorage.setItem(`gatus:endpoint-group:${this.name}:collapsed`, this.collapsed);
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
endpoints: function () {
this.healthCheck();
}
},
created() {
this.healthCheck();
},
data() {
return {
unhealthyCount: 0,
collapsed: localStorage.getItem(`gatus:endpoint-group:${this.name}:collapsed`) === "true"
}
}
}
</script>
<style>
.endpoint-group {
cursor: pointer;
user-select: none;
}
.endpoint-group h5:hover {
color: #1b1e21;
}
</style>

View File

@@ -1,74 +0,0 @@
<template>
<div id="results">
<slot v-for="endpointGroup in endpointGroups" :key="endpointGroup">
<EndpointGroup :endpoints="endpointGroup.endpoints" :name="endpointGroup.name" @showTooltip="showTooltip" @toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime" />
</slot>
</div>
</template>
<script>
import EndpointGroup from './EndpointGroup.vue';
export default {
name: 'Endpoints',
components: {
EndpointGroup
},
props: {
showStatusOnHover: Boolean,
endpointStatuses: Object,
showAverageResponseTime: Boolean
},
emits: ['showTooltip', 'toggleShowAverageResponseTime'],
methods: {
process() {
let outputByGroup = {};
for (let endpointStatusIndex in this.endpointStatuses) {
let endpointStatus = this.endpointStatuses[endpointStatusIndex];
// create an empty entry if this group is new
if (!outputByGroup[endpointStatus.group] || outputByGroup[endpointStatus.group].length === 0) {
outputByGroup[endpointStatus.group] = [];
}
outputByGroup[endpointStatus.group].push(endpointStatus);
}
let endpointGroups = [];
for (let name in outputByGroup) {
if (name !== 'undefined') {
endpointGroups.push({name: name, endpoints: outputByGroup[name]})
}
}
// Add all endpoints that don't have a group at the end
if (outputByGroup['undefined']) {
endpointGroups.push({name: 'undefined', endpoints: outputByGroup['undefined']})
}
this.endpointGroups = endpointGroups;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
},
toggleShowAverageResponseTime() {
this.$emit('toggleShowAverageResponseTime');
}
},
watch: {
endpointStatuses: function () {
this.process();
}
},
data() {
return {
userClickedStatus: false,
endpointGroups: []
}
}
}
</script>
<style>
.endpoint-group-content > div:nth-child(1) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
</style>

View File

@@ -1,11 +1,35 @@
<template>
<div class="flex justify-center items-center mx-auto">
<img :class="`animate-spin opacity-60 rounded-full`" src="../assets/logo.svg" alt="Gatus logo" />
<div class="flex justify-center items-center">
<img
:class="[
'animate-spin rounded-full opacity-60 grayscale',
sizeClass,
]"
src="../assets/logo.svg"
alt="Gatus logo"
/>
</div>
</template>
<script>
export default {
<script setup>
import { computed } from 'vue'
}
</script>
const props = defineProps({
size: {
type: String,
default: 'md',
validator: (value) => ['xs', 'sm', 'md', 'lg', 'xl'].includes(value)
},
})
const sizeClass = computed(() => {
const sizes = {
xs: 'w-4 h-4',
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-12 h-12',
xl: 'w-16 h-16'
}
return sizes[props.size] || sizes.md
})
</script>

View File

@@ -1,42 +1,73 @@
<template>
<div class="mt-3 flex">
<div class="flex-1">
<button v-if="currentPage < maxPages" @click="nextPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">&lt;</button>
</div>
<div class="flex-1 text-right">
<button v-if="currentPage > 1" @click="previousPage" class="bg-gray-100 hover:bg-gray-200 text-gray-500 border border-gray-200 px-2 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">&gt;</button>
</div>
<div class="flex items-center justify-between">
<Button
variant="outline"
size="sm"
:disabled="currentPage >= maxPages"
@click="previousPage"
class="flex items-center gap-1"
>
<ChevronLeft class="h-4 w-4" />
Previous
</Button>
<span class="text-sm text-muted-foreground">
Page {{ currentPage }} of {{ maxPages }}
</span>
<Button
variant="outline"
size="sm"
:disabled="currentPage <= 1"
@click="nextPage"
class="flex items-center gap-1"
>
Next
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
<script>
export default {
name: 'Pagination',
props: {
numberOfResultsPerPage: Number,
},
components: {},
emits: ['page'],
methods: {
nextPage() {
this.currentPage++;
this.$emit('page', this.currentPage);
},
previousPage() {
this.currentPage--;
this.$emit('page', this.currentPage);
}
},
computed: {
maxPages() {
return Math.ceil(parseInt(window.config.maximumNumberOfResults) / this.numberOfResultsPerPage)
}
},
data() {
return {
currentPage: 1,
const props = defineProps({
numberOfResultsPerPage: Number,
currentPageProp: {
type: Number,
default: 1
}
})
const emit = defineEmits(['page'])
const currentPage = ref(props.currentPageProp)
const maxPages = computed(() => {
// Use maximumNumberOfResults from config if available, otherwise default to 100
let maxResults = 100 // Default value
// Check if window.config exists and has maximumNumberOfResults
if (typeof window !== 'undefined' && window.config && window.config.maximumNumberOfResults) {
const parsed = parseInt(window.config.maximumNumberOfResults)
if (!isNaN(parsed)) {
maxResults = parsed
}
}
return Math.ceil(maxResults / props.numberOfResultsPerPage)
})
const nextPage = () => {
// "Next" should show newer data (lower page numbers)
currentPage.value--
emit('page', currentPage.value)
}
const previousPage = () => {
// "Previous" should show older data (higher page numbers)
currentPage.value++
emit('page', currentPage.value)
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div class="flex flex-col lg:flex-row gap-3 lg:gap-4 p-3 sm:p-4 bg-card rounded-lg border">
<div class="flex-1">
<div class="relative">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<label for="search-input" class="sr-only">Search endpoints</label>
<Input
id="search-input"
v-model="searchQuery"
type="text"
placeholder="Search endpoints..."
class="pl-10 text-sm sm:text-base"
@input="$emit('search', searchQuery)"
/>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Filter by:</label>
<Select
v-model="filterBy"
:options="filterOptions"
placeholder="Nothing"
class="flex-1 sm:w-[140px] md:w-[160px]"
@update:model-value="handleFilterChange"
/>
</div>
<div class="flex items-center gap-2 flex-1 sm:flex-initial">
<label class="text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap">Sort by:</label>
<Select
v-model="sortBy"
:options="sortOptions"
placeholder="Name"
class="flex-1 sm:w-[90px] md:w-[100px]"
@update:model-value="handleSortChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Search } from 'lucide-vue-next'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
const searchQuery = ref('')
const filterBy = ref(localStorage.getItem('gatus:filter-by') || (typeof window !== 'undefined' && window.config?.defaultFilterBy) || 'nothing')
const sortBy = ref(localStorage.getItem('gatus:sort-by') || (typeof window !== 'undefined' && window.config?.defaultSortBy) || 'name')
const filterOptions = [
{ label: 'Nothing', value: 'nothing' },
{ label: 'Failing', value: 'failing' },
{ label: 'Unstable', value: 'unstable' }
]
const sortOptions = [
{ label: 'Name', value: 'name' },
{ label: 'Group', value: 'group' },
{ label: 'Health', value: 'health' }
]
const emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
const handleFilterChange = (value) => {
filterBy.value = value
localStorage.setItem('gatus:filter-by', value)
// Reset all filter states first
emit('update:showOnlyFailing', false)
emit('update:showRecentFailures', false)
// Apply the selected filter
if (value === 'failing') {
emit('update:showOnlyFailing', true)
} else if (value === 'unstable') {
emit('update:showRecentFailures', true)
}
}
const handleSortChange = (value) => {
sortBy.value = value
localStorage.setItem('gatus:sort-by', value)
emit('update:sortBy', value)
emit('update:groupByGroup', value === 'group')
// When switching to group view, initialize collapsed groups
if (value === 'group') {
emit('initializeCollapsedGroups')
}
}
onMounted(() => {
// Apply saved filter/sort state on load
handleFilterChange(filterBy.value)
handleSortChange(sortBy.value)
})
</script>

View File

@@ -1,104 +1,190 @@
<template>
<div id="settings" class="flex bg-gray-200 border-gray-300 rounded border shadow dark:text-gray-200 dark:bg-gray-800 dark:border-gray-500">
<div class="text-xs text-gray-600 rounded-xl py-1.5 px-1.5 dark:text-gray-200">
<ArrowPathIcon class="w-3"/>
<div id="settings" class="fixed bottom-4 left-4 z-50">
<div class="flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-full shadow-md p-1">
<!-- Refresh Rate -->
<button
@click="showRefreshMenu = !showRefreshMenu"
:aria-label="`Refresh interval: ${formatRefreshInterval(refreshIntervalValue)}`"
:aria-expanded="showRefreshMenu"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full hover:bg-accent transition-colors relative"
>
<RefreshCw class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium">{{ formatRefreshInterval(refreshIntervalValue) }}</span>
<!-- Refresh Rate Dropdown -->
<div
v-if="showRefreshMenu"
@click.stop
class="absolute bottom-full left-0 mb-2 bg-popover border rounded-lg shadow-lg overflow-hidden"
>
<button
v-for="interval in REFRESH_INTERVALS"
:key="interval.value"
@click="selectRefreshInterval(interval.value)"
:class="[
'block w-full px-4 py-2 text-xs text-left hover:bg-accent transition-colors',
refreshIntervalValue === interval.value && 'bg-accent'
]"
>
{{ interval.label }}
</button>
</div>
</button>
<!-- Divider -->
<div class="h-5 w-px bg-border/50" />
<!-- Theme Toggle -->
<button
@click="toggleDarkMode"
:aria-label="darkMode ? 'Switch to light mode' : 'Switch to dark mode'"
class="p-1.5 rounded-full hover:bg-accent transition-colors group relative"
>
<Sun v-if="darkMode" class="h-3.5 w-3.5 transition-all" />
<Moon v-else class="h-3.5 w-3.5 transition-all" />
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded-md shadow-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap">
{{ darkMode ? 'Light mode' : 'Dark mode' }}
</div>
</button>
</div>
<select class="text-center text-gray-500 text-xs dark:text-gray-200 dark:bg-gray-800 border-r border-l border-gray-300 dark:border-gray-500 pl-1" id="refresh-rate" ref="refreshInterval" @change="handleChangeRefreshInterval">
<option value="10" :selected="refreshInterval === 10">10s</option>
<option value="30" :selected="refreshInterval === 30">30s</option>
<option value="60" :selected="refreshInterval === 60">1m</option>
<option value="120" :selected="refreshInterval === 120">2m</option>
<option value="300" :selected="refreshInterval === 300">5m</option>
<option value="600" :selected="refreshInterval === 600">10m</option>
</select>
<button @click="toggleDarkMode" class="text-xs p-1">
<slot v-if="darkMode"><SunIcon class="w-4"/></slot>
<slot v-else><MoonIcon class="w-4 text-gray-500"/></slot>
</button>
</div>
</template>
<script>
import { MoonIcon, SunIcon } from '@heroicons/vue/20/solid'
import { ArrowPathIcon } from '@heroicons/vue/24/solid'
<script setup>
/* eslint-disable no-undef */
import { ref, onMounted, onUnmounted } from 'vue'
import { Sun, Moon, RefreshCw } from 'lucide-vue-next'
const emit = defineEmits(['refreshData'])
// Constants
const REFRESH_INTERVALS = [
{ value: '10', label: '10s' },
{ value: '30', label: '30s' },
{ value: '60', label: '1m' },
{ value: '120', label: '2m' },
{ value: '300', label: '5m' },
{ value: '600', label: '10m' }
]
const DEFAULT_REFRESH_INTERVAL = '300'
const THEME_COOKIE_NAME = 'theme'
const THEME_COOKIE_MAX_AGE = 31536000 // 1 year
const STORAGE_KEYS = {
REFRESH_INTERVAL: 'gatus:refresh-interval'
}
// Helper functions
function wantsDarkMode() {
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
return themeFromCookie === 'dark' || !themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark"));
const themeFromCookie = document.cookie.match(new RegExp(`${THEME_COOKIE_NAME}=(dark|light);?`))?.[1]
return themeFromCookie === 'dark' || (!themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains("dark")))
}
export default {
name: 'Settings',
components: {
ArrowPathIcon,
MoonIcon,
SunIcon
},
props: {},
methods: {
setRefreshInterval(seconds) {
localStorage.setItem('gatus:refresh-interval', seconds);
let that = this;
this.refreshIntervalHandler = setInterval(function () {
that.refreshData();
}, seconds * 1000);
},
refreshData() {
this.$emit('refreshData');
},
handleChangeRefreshInterval() {
this.refreshData();
clearInterval(this.refreshIntervalHandler);
this.setRefreshInterval(this.$refs.refreshInterval.value);
},
toggleDarkMode() {
if (wantsDarkMode()) {
document.cookie = `theme=light; path=/; max-age=31536000; samesite=strict`;
} else {
document.cookie = `theme=dark; path=/; max-age=31536000; samesite=strict`;
}
this.applyTheme();
},
applyTheme() {
if (wantsDarkMode()) {
this.darkMode = true;
document.documentElement.classList.add('dark');
} else {
this.darkMode = false;
document.documentElement.classList.remove('dark');
}
}
},
created() {
if (this.refreshInterval !== 10 && this.refreshInterval !== 30 && this.refreshInterval !== 60 && this.refreshInterval !== 120 && this.refreshInterval !== 300 && this.refreshInterval !== 600) {
this.refreshInterval = 300;
}
this.setRefreshInterval(this.refreshInterval);
this.applyTheme();
},
unmounted() {
clearInterval(this.refreshIntervalHandler);
},
data() {
return {
refreshInterval: localStorage.getItem('gatus:refresh-interval') < 10 ? 300 : parseInt(localStorage.getItem('gatus:refresh-interval')),
refreshIntervalHandler: 0,
darkMode: wantsDarkMode()
}
},
function getStoredRefreshInterval() {
const stored = localStorage.getItem(STORAGE_KEYS.REFRESH_INTERVAL)
const parsedValue = stored && parseInt(stored)
const isValid = parsedValue && parsedValue >= 10 && REFRESH_INTERVALS.some(i => i.value === stored)
return isValid ? stored : DEFAULT_REFRESH_INTERVAL
}
// State
const refreshIntervalValue = ref(getStoredRefreshInterval())
const darkMode = ref(wantsDarkMode())
const showRefreshMenu = ref(false)
let refreshIntervalHandler = null
// Methods
const formatRefreshInterval = (value) => {
const interval = REFRESH_INTERVALS.find(i => i.value === value)
return interval ? interval.label : `${value}s`
}
const setRefreshInterval = (seconds) => {
localStorage.setItem(STORAGE_KEYS.REFRESH_INTERVAL, seconds)
if (refreshIntervalHandler) {
clearInterval(refreshIntervalHandler)
}
refreshIntervalHandler = setInterval(() => {
refreshData()
}, seconds * 1000)
}
const refreshData = () => {
emit('refreshData')
}
const selectRefreshInterval = (value) => {
refreshIntervalValue.value = value
showRefreshMenu.value = false
refreshData()
setRefreshInterval(value)
}
// Close menu when clicking outside
const handleClickOutside = (event) => {
const settings = document.getElementById('settings')
if (settings && !settings.contains(event.target)) {
showRefreshMenu.value = false
}
}
const setThemeCookie = (theme) => {
document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; samesite=strict`
}
const toggleDarkMode = () => {
const newTheme = wantsDarkMode() ? 'light' : 'dark'
setThemeCookie(newTheme)
applyTheme()
}
const applyTheme = () => {
const isDark = wantsDarkMode()
darkMode.value = isDark
document.documentElement.classList.toggle('dark', isDark)
}
// Lifecycle
onMounted(() => {
setRefreshInterval(refreshIntervalValue.value)
applyTheme()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
if (refreshIntervalHandler) {
clearInterval(refreshIntervalHandler)
}
document.removeEventListener('click', handleClickOutside)
})
</script>
<style>
#settings {
position: fixed;
left: 10px;
bottom: 10px;
<style scoped>
/* Animations for smooth transitions */
@keyframes slideIn {
from {
transform: translateX(-20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
#settings select:focus {
box-shadow: none;
#settings {
animation: slideIn 0.3s ease-out;
}
#settings > div {
transition: all 0.2s ease;
}
#settings > div:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
</style>

View File

@@ -8,14 +8,9 @@
</div>
</template>
<script>
export default {
name: 'Social'
}
<script setup>
</script>
<style scoped>
#social {
position: fixed;
@@ -33,4 +28,4 @@ export default {
#social img:hover {
opacity: 1;
}
</style>
</style>

View File

@@ -0,0 +1,58 @@
<template>
<Badge :variant="variant" class="flex items-center gap-1">
<span :class="['w-2 h-2 rounded-full', dotClass]"></span>
{{ label }}
</Badge>
</template>
<script setup>
import { computed } from 'vue'
import { Badge } from '@/components/ui/badge'
const props = defineProps({
status: {
type: String,
required: true,
validator: (value) => ['healthy', 'unhealthy', 'degraded', 'unknown'].includes(value)
}
})
const variant = computed(() => {
switch (props.status) {
case 'healthy':
return 'success'
case 'unhealthy':
return 'destructive'
case 'degraded':
return 'warning'
default:
return 'secondary'
}
})
const label = computed(() => {
switch (props.status) {
case 'healthy':
return 'Healthy'
case 'unhealthy':
return 'Unhealthy'
case 'degraded':
return 'Degraded'
default:
return 'Unknown'
}
})
const dotClass = computed(() => {
switch (props.status) {
case 'healthy':
return 'bg-green-400'
case 'unhealthy':
return 'bg-red-400'
case 'degraded':
return 'bg-yellow-400'
default:
return 'bg-gray-400'
}
})
</script>

View File

@@ -1,130 +1,158 @@
<template>
<div id="tooltip" ref="tooltip" :class="hidden ? 'invisible' : ''" :style="'top:' + top + 'px; left:' + left + 'px'">
<slot v-if="result">
<div class="tooltip-title">Timestamp:</div>
<code id="tooltip-timestamp">{{ prettifyTimestamp(result.timestamp) }}</code>
<div class="tooltip-title">Response time:</div>
<code id="tooltip-response-time">{{ (result.duration / 1000000).toFixed(0) }}ms</code>
<slot v-if="result.conditionResults && result.conditionResults.length">
<div class="tooltip-title">Conditions:</div>
<code id="tooltip-conditions">
<slot v-for="conditionResult in result.conditionResults" :key="conditionResult">
{{ conditionResult.success ? "&#10003;" : "X" }} ~ {{ conditionResult.condition }}<br/>
</slot>
</code>
</slot>
<div id="tooltip-errors-container" v-if="result.errors && result.errors.length">
<div class="tooltip-title">Errors:</div>
<code id="tooltip-errors">
<slot v-for="error in result.errors" :key="error">
- {{ error }}<br/>
</slot>
</code>
<div
id="tooltip"
ref="tooltip"
:class="[
'fixed z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
'bg-popover text-popover-foreground border-border',
hidden ? 'invisible opacity-0' : 'visible opacity-100'
]"
:style="`top: ${top}px; left: ${left}px;`"
>
<div v-if="result" class="space-y-2">
<!-- Timestamp -->
<div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Timestamp</div>
<div class="font-mono text-xs">{{ prettifyTimestamp(result.timestamp) }}</div>
</div>
</slot>
<!-- Response Time -->
<div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Response Time</div>
<div class="font-mono text-xs">{{ (result.duration / 1000000).toFixed(0) }}ms</div>
</div>
<!-- Conditions -->
<div v-if="result.conditionResults && result.conditionResults.length">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Conditions</div>
<div class="font-mono text-xs space-y-0.5">
<div
v-for="(conditionResult, index) in result.conditionResults"
:key="index"
class="flex items-start gap-1"
>
<span :class="conditionResult.success ? 'text-green-500' : 'text-red-500'">
{{ conditionResult.success ? '✓' : '✗' }}
</span>
<span class="break-all">{{ conditionResult.condition }}</span>
</div>
</div>
</div>
<!-- Errors -->
<div v-if="result.errors && result.errors.length">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Errors</div>
<div class="font-mono text-xs space-y-0.5">
<div v-for="(error, index) in result.errors" :key="index" class="text-red-500">
{{ error }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, watch, nextTick } from 'vue'
import { helper } from '@/mixins/helper'
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Endpoints',
props: {
event: Event,
result: Object
const props = defineProps({
event: {
type: [Event, Object],
default: null
},
mixins: [helper],
methods: {
htmlEntities(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
},
reposition() {
if (this.event && this.event.type) {
if (this.event.type === 'mouseenter') {
let targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
let targetLeftPosition = this.event.target.getBoundingClientRect().x;
let tooltipBoundingClientRect = this.$refs.tooltip.getBoundingClientRect();
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
targetLeftPosition = this.event.target.getBoundingClientRect().x - tooltipBoundingClientRect.width + this.event.target.getBoundingClientRect().width;
if (targetLeftPosition < 0) {
targetLeftPosition += -targetLeftPosition;
}
}
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
if (targetTopPosition < 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
}
}
this.top = targetTopPosition;
this.left = targetLeftPosition;
} else if (this.event.type === 'mouseleave') {
this.hidden = true;
result: {
type: Object,
default: null
}
})
// State
const hidden = ref(true)
const top = ref(0)
const left = ref(0)
const tooltip = ref(null)
// Methods from helper mixin
const { prettifyTimestamp } = helper.methods
const reposition = async () => {
if (!props.event || !props.event.type) return
await nextTick()
if (props.event.type === 'mouseenter' && tooltip.value) {
const target = props.event.target
const targetRect = target.getBoundingClientRect()
// First, position tooltip to get its dimensions
hidden.value = false
await nextTick()
const tooltipRect = tooltip.value.getBoundingClientRect()
// 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
}
}
}
},
watch: {
event: function (value) {
if (value && value.type) {
if (value.type === 'mouseenter') {
this.hidden = false;
} else if (value.type === 'mouseleave') {
this.hidden = true;
}
// 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
}
}
},
updated() {
this.reposition();
},
created() {
this.reposition();
},
data() {
return {
hidden: true,
top: 0,
left: 0
}
top.value = Math.round(newTop)
left.value = Math.round(newLeft)
} else if (props.event.type === 'mouseleave') {
hidden.value = true
}
}
</script>
// Watchers
watch(() => props.event, (newEvent) => {
if (newEvent && newEvent.type) {
if (newEvent.type === 'mouseenter') {
hidden.value = false
nextTick(() => reposition())
} else if (newEvent.type === 'mouseleave') {
hidden.value = true
}
}
}, { immediate: true })
<style>
#tooltip {
position: fixed;
background-color: white;
border: 1px solid lightgray;
border-radius: 4px;
padding: 6px;
font-size: 13px;
}
#tooltip code {
color: #212529;
line-height: 1;
}
#tooltip .tooltip-title {
font-weight: bold;
margin-bottom: 0;
display: block;
}
#tooltip .tooltip-title {
margin-top: 8px;
}
#tooltip > .tooltip-title:first-child {
margin-top: 0;
}
</style>
watch(() => props.result, () => {
if (!hidden.value) {
nextTick(() => reposition())
}
})
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div :class="combineClasses(badgeVariants({ variant }), $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
defineProps({
variant: {
type: String,
default: 'default',
},
})
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success: 'border-transparent bg-green-500 text-white',
warning: 'border-transparent bg-yellow-500 text-white',
},
},
defaultVariants: {
variant: 'default',
},
}
)
</script>

View File

@@ -0,0 +1 @@
export { default as Badge } from './Badge.vue'

View File

@@ -0,0 +1,55 @@
<template>
<button
:class="combineClasses(buttonVariants({ variant, size }), $attrs.class ?? '')"
:disabled="disabled"
>
<slot />
</button>
</template>
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
defineProps({
variant: {
type: String,
default: 'default',
},
size: {
type: String,
default: 'default',
},
disabled: {
type: Boolean,
default: false,
},
})
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
</script>

View File

@@ -0,0 +1 @@
export { default as Button } from './Button.vue'

View File

@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('rounded-lg border bg-card text-card-foreground shadow-sm', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('p-6 pt-0', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div :class="combineClasses('flex flex-col space-y-1.5 p-6', $attrs.class ?? '')">
<slot />
</div>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<h3 :class="combineClasses('text-2xl font-semibold leading-none tracking-tight', $attrs.class ?? '')">
<slot />
</h3>
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
</script>

View File

@@ -0,0 +1,4 @@
export { default as Card } from './Card.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as CardContent } from './CardContent.vue'

View File

@@ -0,0 +1,24 @@
<template>
<input
:class="combineClasses(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
$attrs.class ?? ''
)"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
/* eslint-disable no-undef */
import { combineClasses } from '@/lib/utils'
defineProps({
modelValue: {
type: [String, Number],
default: '',
},
})
defineEmits(['update:modelValue'])
</script>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View File

@@ -0,0 +1,127 @@
<template>
<div ref="selectRef" class="relative" :class="props.class">
<button
@click="toggleDropdown"
@keydown="handleKeyDown"
:aria-expanded="isOpen"
:aria-haspopup="true"
:aria-label="selectedOption.label || props.placeholder"
class="flex h-9 sm:h-10 w-full items-center justify-between rounded-md border border-input bg-background px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<span class="truncate">{{ selectedOption.label }}</span>
<ChevronDown class="h-3 w-3 sm:h-4 sm:w-4 opacity-50 flex-shrink-0 ml-1" />
</button>
<div
v-if="isOpen"
role="listbox"
class="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
>
<div class="p-1">
<div
v-for="(option, index) in options"
:key="option.value"
@click="selectOption(option)"
:class="[
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-6 sm:pl-8 pr-2 text-xs sm:text-sm outline-none hover:bg-accent hover:text-accent-foreground',
index === focusedIndex && 'bg-accent text-accent-foreground'
]"
role="option"
:aria-selected="modelValue === option.value"
>
<span class="absolute left-1.5 sm:left-2 flex h-3.5 w-3.5 items-center justify-center">
<Check v-if="modelValue === option.value" class="h-3 w-3 sm:h-4 sm:w-4" />
</span>
{{ option.label }}
</div>
</div>
</div>
</div>
</template>
<script setup>
/* eslint-disable no-undef */
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'
const props = defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, required: true },
placeholder: { type: String, default: 'Select...' },
class: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const isOpen = ref(false)
const selectRef = ref(null)
const focusedIndex = ref(-1)
const selectedOption = computed(() => {
return props.options.find(option => option.value === props.modelValue) || { label: props.placeholder, value: '' }
})
const selectOption = (option) => {
emit('update:modelValue', option.value)
isOpen.value = false
}
const toggleDropdown = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
// Set initial focus to selected option or first option
const selectedIdx = props.options.findIndex(opt => opt.value === props.modelValue)
focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
} else {
focusedIndex.value = -1
}
}
const handleClickOutside = (event) => {
if (selectRef.value && !selectRef.value.contains(event.target)) {
isOpen.value = false
focusedIndex.value = -1
}
}
const handleKeyDown = (event) => {
if (!isOpen.value) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
toggleDropdown()
}
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusedIndex.value = Math.min(focusedIndex.value + 1, props.options.length - 1)
break
case 'ArrowUp':
event.preventDefault()
focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
break
case 'Enter':
case ' ':
event.preventDefault()
if (focusedIndex.value >= 0 && focusedIndex.value < props.options.length) {
selectOption(props.options[focusedIndex.value])
}
break
case 'Escape':
event.preventDefault()
isOpen.value = false
focusedIndex.value = -1
break
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -0,0 +1 @@
export { default as Select } from './Select.vue'