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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user