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:
@@ -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"> </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"> </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'>
|
||||
|
||||
</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>
|
||||
159
web/app/src/components/EndpointCard.vue
Normal file
159
web/app/src/components/EndpointCard.vue
Normal 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>
|
||||
@@ -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 ? '▼' : '▲' }}
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"><</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">></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>
|
||||
100
web/app/src/components/SearchBar.vue
Normal file
100
web/app/src/components/SearchBar.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
58
web/app/src/components/StatusBadge.vue
Normal file
58
web/app/src/components/StatusBadge.vue
Normal 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>
|
||||
@@ -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 ? "✓" : "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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
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>
|
||||
37
web/app/src/components/ui/badge/Badge.vue
Normal file
37
web/app/src/components/ui/badge/Badge.vue
Normal 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>
|
||||
1
web/app/src/components/ui/badge/index.js
Normal file
1
web/app/src/components/ui/badge/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Badge } from './Badge.vue'
|
||||
55
web/app/src/components/ui/button/Button.vue
Normal file
55
web/app/src/components/ui/button/Button.vue
Normal 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>
|
||||
1
web/app/src/components/ui/button/index.js
Normal file
1
web/app/src/components/ui/button/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Button } from './Button.vue'
|
||||
9
web/app/src/components/ui/card/Card.vue
Normal file
9
web/app/src/components/ui/card/Card.vue
Normal 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>
|
||||
9
web/app/src/components/ui/card/CardContent.vue
Normal file
9
web/app/src/components/ui/card/CardContent.vue
Normal 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>
|
||||
9
web/app/src/components/ui/card/CardHeader.vue
Normal file
9
web/app/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||
9
web/app/src/components/ui/card/CardTitle.vue
Normal file
9
web/app/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||
4
web/app/src/components/ui/card/index.js
Normal file
4
web/app/src/components/ui/card/index.js
Normal 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'
|
||||
24
web/app/src/components/ui/input/Input.vue
Normal file
24
web/app/src/components/ui/input/Input.vue
Normal 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>
|
||||
1
web/app/src/components/ui/input/index.js
Normal file
1
web/app/src/components/ui/input/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
127
web/app/src/components/ui/select/Select.vue
Normal file
127
web/app/src/components/ui/select/Select.vue
Normal 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>
|
||||
1
web/app/src/components/ui/select/index.js
Normal file
1
web/app/src/components/ui/select/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Select } from './Select.vue'
|
||||
Reference in New Issue
Block a user