mirror of
https://github.com/TwiN/gatus.git
synced 2026-02-11 11:54:16 +00:00
feat(suite): Implement Suites (#1239)
* feat(suite): Implement Suites Fixes #1230 * Update docs * Fix variable alignment * Prevent always-run endpoint from running if a context placeholder fails to resolve in the URL * Return errors when a context placeholder path fails to resolve * Add a couple of unit tests * Add a couple of unit tests * fix(ui): Update group count properly Fixes #1233 * refactor: Pass down entire config instead of several sub-configs * fix: Change default suite interval and timeout * fix: Deprecate disable-monitoring-lock in favor of concurrency * fix: Make sure there are no duplicate keys * Refactor some code * Update watchdog/watchdog.go * Update web/app/src/components/StepDetailsModal.vue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: Remove useless log * fix: Set default concurrency to 3 instead of 5 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -61,7 +61,7 @@ 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'
|
||||
import { generatePrettyTimeAgo } from '@/utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -145,12 +145,12 @@ const formattedResponseTime = computed(() => {
|
||||
|
||||
const oldestResultTime = computed(() => {
|
||||
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
|
||||
return helper.methods.generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
|
||||
return 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)
|
||||
return generatePrettyTimeAgo(props.endpoint.results[props.endpoint.results.length - 1].timestamp)
|
||||
})
|
||||
|
||||
const navigateToDetails = () => {
|
||||
|
||||
133
web/app/src/components/FlowStep.vue
Normal file
133
web/app/src/components/FlowStep.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="flex items-start gap-4 relative group hover:bg-accent/30 rounded-lg p-2 -m-2 transition-colors cursor-pointer"
|
||||
@click="$emit('step-click')">
|
||||
<!-- Step circle with status icon -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<!-- Connection line from previous step -->
|
||||
<div v-if="index > 0" :class="incomingLineClasses" class="absolute left-1/2 bottom-8 w-0.5 h-4 -translate-x-px"></div>
|
||||
|
||||
<div :class="circleClasses" class="w-8 h-8 rounded-full flex items-center justify-center">
|
||||
<component :is="statusIcon" class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<!-- Connection line to next step -->
|
||||
<div v-if="!isLast" :class="connectionLineClasses" class="absolute left-1/2 top-8 w-0.5 h-4 -translate-x-px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step content -->
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<h4 class="font-medium text-sm truncate">{{ step.name }}</h4>
|
||||
<span class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{{ formatDuration(step.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Step badges -->
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-if="step.isAlwaysRun" class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-md">
|
||||
<RotateCcw class="w-3 h-3" />
|
||||
Always Run
|
||||
</span>
|
||||
<span v-if="step.errors?.length" class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 rounded-md">
|
||||
{{ step.errors.length }} error{{ step.errors.length !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { CheckCircle, XCircle, SkipForward, RotateCcw, Pause } from 'lucide-vue-next'
|
||||
import { formatDuration } from '@/utils/format'
|
||||
|
||||
const props = defineProps({
|
||||
step: { type: Object, required: true },
|
||||
index: { type: Number, required: true },
|
||||
isLast: { type: Boolean, default: false },
|
||||
previousStep: { type: Object, default: null }
|
||||
})
|
||||
|
||||
defineEmits(['step-click'])
|
||||
|
||||
// Status icon mapping
|
||||
const statusIcon = computed(() => {
|
||||
switch (props.step.status) {
|
||||
case 'success': return CheckCircle
|
||||
case 'failed': return XCircle
|
||||
case 'skipped': return SkipForward
|
||||
case 'not-started': return Pause
|
||||
default: return Pause
|
||||
}
|
||||
})
|
||||
|
||||
// Circle styling classes
|
||||
const circleClasses = computed(() => {
|
||||
const baseClasses = 'border-2'
|
||||
|
||||
if (props.step.isAlwaysRun) {
|
||||
// Always-run endpoints get a special ring effect
|
||||
switch (props.step.status) {
|
||||
case 'success':
|
||||
return `${baseClasses} bg-green-500 text-white border-green-600 ring-2 ring-blue-200 dark:ring-blue-800`
|
||||
case 'failed':
|
||||
return `${baseClasses} bg-red-500 text-white border-red-600 ring-2 ring-blue-200 dark:ring-blue-800`
|
||||
default:
|
||||
return `${baseClasses} bg-blue-500 text-white border-blue-600 ring-2 ring-blue-200 dark:ring-blue-800`
|
||||
}
|
||||
}
|
||||
|
||||
switch (props.step.status) {
|
||||
case 'success':
|
||||
return `${baseClasses} bg-green-500 text-white border-green-600`
|
||||
case 'failed':
|
||||
return `${baseClasses} bg-red-500 text-white border-red-600`
|
||||
case 'skipped':
|
||||
return `${baseClasses} bg-gray-400 text-white border-gray-500`
|
||||
case 'not-started':
|
||||
return `${baseClasses} bg-gray-200 text-gray-500 border-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600`
|
||||
default:
|
||||
return `${baseClasses} bg-gray-200 text-gray-500 border-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600`
|
||||
}
|
||||
})
|
||||
|
||||
// Incoming connection line styling (from previous step to this step)
|
||||
const incomingLineClasses = computed(() => {
|
||||
if (!props.previousStep) return 'bg-gray-300 dark:bg-gray-600'
|
||||
|
||||
// If this step is skipped, the line should be dashed/gray
|
||||
if (props.step.status === 'skipped') {
|
||||
return 'border-l-2 border-dashed border-gray-400 bg-transparent'
|
||||
}
|
||||
|
||||
// Otherwise, color based on previous step's status
|
||||
switch (props.previousStep.status) {
|
||||
case 'success':
|
||||
return 'bg-green-500'
|
||||
case 'failed':
|
||||
// If previous failed but this ran (always-run), show red line
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-300 dark:bg-gray-600'
|
||||
}
|
||||
})
|
||||
|
||||
// Outgoing connection line styling (from this step to next)
|
||||
const connectionLineClasses = computed(() => {
|
||||
const nextStep = props.step.nextStepStatus
|
||||
switch (props.step.status) {
|
||||
case 'success':
|
||||
return nextStep === 'skipped'
|
||||
? 'bg-gray-300 dark:bg-gray-600'
|
||||
: 'bg-green-500'
|
||||
case 'failed':
|
||||
return nextStep === 'skipped'
|
||||
? 'border-l-2 border-dashed border-gray-400 bg-transparent'
|
||||
: 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-300 dark:bg-gray-600'
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
124
web/app/src/components/SequentialFlowDiagram.vue
Normal file
124
web/app/src/components/SequentialFlowDiagram.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Timeline header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-sm font-medium text-muted-foreground">Start</div>
|
||||
<div class="flex-1 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-green-500 dark:bg-green-600 rounded-full transition-all duration-300 ease-out"
|
||||
:style="{ width: progressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-muted-foreground">End</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress stats -->
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ completedSteps }}/{{ totalSteps }} steps successful</span>
|
||||
<span v-if="totalDuration > 0">{{ formatDuration(totalDuration) }} total</span>
|
||||
</div>
|
||||
|
||||
<!-- Flow steps -->
|
||||
<div class="space-y-2">
|
||||
<FlowStep
|
||||
v-for="(step, index) in flowSteps"
|
||||
:key="index"
|
||||
:step="step"
|
||||
:index="index"
|
||||
:is-last="index === flowSteps.length - 1"
|
||||
:previous-step="index > 0 ? flowSteps[index - 1] : null"
|
||||
@step-click="$emit('step-selected', step, index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="mt-6 pt-4 border-t">
|
||||
<div class="text-sm font-medium text-muted-foreground mb-2">Status Legend</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div v-if="hasSuccessSteps" class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded-full bg-green-500 flex items-center justify-center">
|
||||
<CheckCircle class="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span class="text-muted-foreground">Success</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasFailedSteps" class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
|
||||
<XCircle class="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span class="text-muted-foreground">Failed</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSkippedSteps" class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded-full bg-gray-400 flex items-center justify-center">
|
||||
<SkipForward class="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span class="text-muted-foreground">Skipped</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasAlwaysRunSteps" class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded-full bg-blue-500 border-2 border-blue-200 dark:border-blue-800 flex items-center justify-center">
|
||||
<RotateCcw class="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span class="text-muted-foreground">Always Run</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { CheckCircle, XCircle, SkipForward, RotateCcw } from 'lucide-vue-next'
|
||||
import FlowStep from './FlowStep.vue'
|
||||
import { formatDuration } from '@/utils/format'
|
||||
|
||||
const props = defineProps({
|
||||
flowSteps: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
progressPercentage: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
completedSteps: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalSteps: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['step-selected'])
|
||||
|
||||
// Use props instead of computing locally for consistency
|
||||
const completedSteps = computed(() => props.completedSteps)
|
||||
const totalSteps = computed(() => props.totalSteps)
|
||||
|
||||
const totalDuration = computed(() => {
|
||||
return props.flowSteps.reduce((total, step) => {
|
||||
return total + (step.duration || 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
// Legend visibility computed properties
|
||||
const hasSuccessSteps = computed(() => {
|
||||
return props.flowSteps.some(step => step.status === 'success')
|
||||
})
|
||||
|
||||
const hasFailedSteps = computed(() => {
|
||||
return props.flowSteps.some(step => step.status === 'failed')
|
||||
})
|
||||
|
||||
const hasSkippedSteps = computed(() => {
|
||||
return props.flowSteps.some(step => step.status === 'skipped')
|
||||
})
|
||||
|
||||
const hasAlwaysRunSteps = computed(() => {
|
||||
return props.flowSteps.some(step => step.isAlwaysRun === true)
|
||||
})
|
||||
|
||||
</script>
|
||||
115
web/app/src/components/StepDetailsModal.vue
Normal file
115
web/app/src/components/StepDetailsModal.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<!-- Modal backdrop -->
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click="$emit('close')">
|
||||
<!-- Modal content -->
|
||||
<div class="bg-background border rounded-lg shadow-lg max-w-2xl w-full max-h-[80vh] overflow-hidden" @click.stop>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
<component :is="statusIcon" :class="iconClasses" class="w-5 h-5" />
|
||||
{{ step.name }}
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Step {{ index + 1 }} • {{ formatDuration(step.duration) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" @click="$emit('close')">
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 space-y-4 overflow-y-auto max-h-[60vh]">
|
||||
<!-- Special properties -->
|
||||
<div v-if="step.isAlwaysRun" class="flex flex-wrap gap-2">
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-700">
|
||||
<RotateCcw class="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-200">Always Run</p>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400">This endpoint is configured to execute even after failures</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Errors section -->
|
||||
<div v-if="step.errors?.length" class="space-y-2">
|
||||
<h3 class="text-sm font-medium flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<AlertCircle class="w-4 h-4" />
|
||||
Errors ({{ step.errors.length }})
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(error, index) in step.errors" :key="index"
|
||||
class="p-3 bg-red-50 dark:bg-red-900/50 border border-red-200 dark:border-red-700 rounded text-sm font-mono text-red-800 dark:text-red-300 break-all">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div v-if="step.result && step.result.timestamp" class="space-y-2">
|
||||
<h3 class="text-sm font-medium flex items-center gap-2">
|
||||
<Clock class="w-4 h-4" />
|
||||
Timestamp
|
||||
</h3>
|
||||
<p class="text-xs font-mono text-muted-foreground">{{ prettifyTimestamp(step.result.timestamp) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Response details -->
|
||||
<div v-if="step.result" class="space-y-2">
|
||||
<h3 class="text-sm font-medium flex items-center gap-2">
|
||||
<Download class="w-4 h-4" />
|
||||
Response
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<span class="text-muted-foreground">Duration:</span>
|
||||
<p class="font-mono mt-1">{{ formatDuration(step.result.duration) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">Success:</span>
|
||||
<p class="mt-1" :class="step.result.success ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
|
||||
{{ step.result.success ? 'Yes' : 'No' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { X, AlertCircle, RotateCcw, Download, CheckCircle, XCircle, SkipForward, Pause, Clock } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDuration } from '@/utils/format'
|
||||
import { prettifyTimestamp } from '@/utils/time'
|
||||
|
||||
const props = defineProps({
|
||||
step: { type: Object, required: true },
|
||||
index: { type: Number, required: true }
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
switch (props.step.status) {
|
||||
case 'success': return CheckCircle
|
||||
case 'failed': return XCircle
|
||||
case 'skipped': return SkipForward
|
||||
case 'not-started': return Pause
|
||||
default: return Pause
|
||||
}
|
||||
})
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
switch (props.step.status) {
|
||||
case 'success': return 'text-green-600 dark:text-green-400'
|
||||
case 'failed': return 'text-red-600 dark:text-red-400'
|
||||
case 'skipped': return 'text-gray-600 dark:text-gray-400'
|
||||
default: return 'text-blue-600 dark:text-blue-400'
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
171
web/app/src/components/SuiteCard.vue
Normal file
171
web/app/src/components/SuiteCard.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<Card class="suite h-full flex flex-col transition hover:shadow-lg hover:scale-[1.01] dark:hover:border-gray-700">
|
||||
<CardHeader class="suite-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="suite.name"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
:aria-label="`View details for suite ${suite.name}`">
|
||||
{{ suite.name }}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
||||
<span v-if="suite.group" class="truncate" :title="suite.group">{{ suite.group }}</span>
|
||||
<span v-if="suite.group && endpointCount">•</span>
|
||||
<span v-if="endpointCount">{{ endpointCount }} endpoint{{ endpointCount !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-2">
|
||||
<StatusBadge :status="currentStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="suite-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">
|
||||
<p class="text-xs text-muted-foreground">Success Rate: {{ successRate }}%</p>
|
||||
<p class="text-xs text-muted-foreground" v-if="averageDuration">{{ averageDuration }}ms avg</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 && showTooltip(result, $event)"
|
||||
@mouseleave="hideTooltip($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>{{ newestResultTime }}</span>
|
||||
<span>{{ oldestResultTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 { generatePrettyTimeAgo } from '@/utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
suite: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
maxResults: {
|
||||
type: Number,
|
||||
default: 50
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
// Computed properties
|
||||
const displayResults = computed(() => {
|
||||
const results = [...(props.suite.results || [])]
|
||||
while (results.length < props.maxResults) {
|
||||
results.unshift(null)
|
||||
}
|
||||
return results.slice(-props.maxResults)
|
||||
})
|
||||
|
||||
const currentStatus = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 'unknown'
|
||||
}
|
||||
return props.suite.results[props.suite.results.length - 1].success ? 'healthy' : 'unhealthy'
|
||||
})
|
||||
|
||||
const endpointCount = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const latestResult = props.suite.results[props.suite.results.length - 1]
|
||||
return latestResult.endpointResults ? latestResult.endpointResults.length : 0
|
||||
})
|
||||
|
||||
const successRate = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const successful = props.suite.results.filter(r => r.success).length
|
||||
return Math.round((successful / props.suite.results.length) * 100)
|
||||
})
|
||||
|
||||
const averageDuration = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const total = props.suite.results.reduce((sum, r) => sum + (r.duration || 0), 0)
|
||||
// Convert nanoseconds to milliseconds
|
||||
return Math.round((total / props.suite.results.length) / 1000000)
|
||||
})
|
||||
|
||||
const oldestResultTime = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
const oldestResult = props.suite.results[0]
|
||||
return generatePrettyTimeAgo(oldestResult.timestamp)
|
||||
})
|
||||
|
||||
const newestResultTime = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 'Now'
|
||||
}
|
||||
|
||||
const newestResult = props.suite.results[props.suite.results.length - 1]
|
||||
return generatePrettyTimeAgo(newestResult.timestamp)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const navigateToDetails = () => {
|
||||
router.push(`/suites/${props.suite.key}`)
|
||||
}
|
||||
|
||||
const showTooltip = (result, event) => {
|
||||
emit('showTooltip', result, event)
|
||||
}
|
||||
|
||||
const hideTooltip = (event) => {
|
||||
emit('showTooltip', null, event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.suite {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.suite:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.suite-header {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark .suite-header {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
</style>
|
||||
@@ -10,20 +10,62 @@
|
||||
:style="`top: ${top}px; left: ${left}px;`"
|
||||
>
|
||||
<div v-if="result" class="space-y-2">
|
||||
<!-- Status (for suite results) -->
|
||||
<div v-if="isSuiteResult" class="flex items-center gap-2">
|
||||
<span :class="[
|
||||
'inline-block w-2 h-2 rounded-full',
|
||||
result.success ? 'bg-green-500' : 'bg-red-500'
|
||||
]"></span>
|
||||
<span class="text-xs font-semibold">
|
||||
{{ result.success ? 'Suite Passed' : 'Suite Failed' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Suite Info (for suite results) -->
|
||||
<div v-if="isSuiteResult && result.endpointResults">
|
||||
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Endpoints</div>
|
||||
<div class="font-mono text-xs">
|
||||
<span :class="successCount === endpointCount ? 'text-green-500' : 'text-yellow-500'">
|
||||
{{ successCount }}/{{ endpointCount }} passed
|
||||
</span>
|
||||
</div>
|
||||
<!-- Endpoint breakdown -->
|
||||
<div v-if="result.endpointResults.length > 0" class="mt-1 space-y-0.5">
|
||||
<div
|
||||
v-for="(endpoint, index) in result.endpointResults.slice(0, 5)"
|
||||
:key="index"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
>
|
||||
<span :class="endpoint.success ? 'text-green-500' : 'text-red-500'">
|
||||
{{ endpoint.success ? '✓' : '✗' }}
|
||||
</span>
|
||||
<span class="truncate">{{ endpoint.name }}</span>
|
||||
<span class="text-muted-foreground">({{ (endpoint.duration / 1000000).toFixed(0) }}ms)</span>
|
||||
</div>
|
||||
<div v-if="result.endpointResults.length > 5" class="text-xs text-muted-foreground">
|
||||
... and {{ result.endpointResults.length - 5 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{{ isSuiteResult ? 'Total Duration' : 'Response Time' }}
|
||||
</div>
|
||||
<div class="font-mono text-xs">
|
||||
{{ isSuiteResult ? (result.duration / 1000000).toFixed(0) : (result.duration / 1000000).toFixed(0) }}ms
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conditions -->
|
||||
<div v-if="result.conditionResults && result.conditionResults.length">
|
||||
<!-- Conditions (for endpoint results) -->
|
||||
<div v-if="!isSuiteResult && 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
|
||||
@@ -54,8 +96,8 @@
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { helper } from '@/mixins/helper'
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import { prettifyTimestamp } from '@/utils/time'
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
@@ -74,8 +116,22 @@ const top = ref(0)
|
||||
const left = ref(0)
|
||||
const tooltip = ref(null)
|
||||
|
||||
// Methods from helper mixin
|
||||
const { prettifyTimestamp } = helper.methods
|
||||
// Computed properties
|
||||
const isSuiteResult = computed(() => {
|
||||
return props.result && props.result.endpointResults !== undefined
|
||||
})
|
||||
|
||||
const endpointCount = computed(() => {
|
||||
if (!isSuiteResult.value || !props.result.endpointResults) return 0
|
||||
return props.result.endpointResults.length
|
||||
})
|
||||
|
||||
const successCount = computed(() => {
|
||||
if (!isSuiteResult.value || !props.result.endpointResults) return 0
|
||||
return props.result.endpointResults.filter(e => e.success).length
|
||||
})
|
||||
|
||||
// Methods are imported from utils/time
|
||||
|
||||
const reposition = async () => {
|
||||
if (!props.event || !props.event.type) return
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export const helper = {
|
||||
methods: {
|
||||
generatePrettyTimeAgo(t) {
|
||||
let differenceInMs = new Date().getTime() - new Date(t).getTime();
|
||||
if (differenceInMs < 500) {
|
||||
return "now";
|
||||
}
|
||||
if (differenceInMs > 3 * 86400000) { // If it was more than 3 days ago, we'll display the number of days ago
|
||||
let days = (differenceInMs / 86400000).toFixed(0);
|
||||
return days + " day" + (days !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
if (differenceInMs > 3600000) { // If it was more than 1h ago, display the number of hours ago
|
||||
let hours = (differenceInMs / 3600000).toFixed(0);
|
||||
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
if (differenceInMs > 60000) {
|
||||
let minutes = (differenceInMs / 60000).toFixed(0);
|
||||
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
let seconds = (differenceInMs / 1000).toFixed(0);
|
||||
return seconds + " second" + (seconds !== "1" ? "s" : "") + " ago";
|
||||
},
|
||||
generatePrettyTimeDifference(start, end) {
|
||||
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
|
||||
return minutes + (minutes === 1 ? ' minute' : ' minutes');
|
||||
},
|
||||
prettifyTimestamp(timestamp) {
|
||||
let date = new Date(timestamp);
|
||||
let YYYY = date.getFullYear();
|
||||
let MM = ((date.getMonth() + 1) < 10 ? "0" : "") + "" + (date.getMonth() + 1);
|
||||
let DD = ((date.getDate()) < 10 ? "0" : "") + "" + (date.getDate());
|
||||
let hh = ((date.getHours()) < 10 ? "0" : "") + "" + (date.getHours());
|
||||
let mm = ((date.getMinutes()) < 10 ? "0" : "") + "" + (date.getMinutes());
|
||||
let ss = ((date.getSeconds()) < 10 ? "0" : "") + "" + (date.getSeconds());
|
||||
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {createRouter, createWebHistory} from 'vue-router'
|
||||
import Home from '@/views/Home'
|
||||
import Details from "@/views/Details";
|
||||
import EndpointDetails from "@/views/EndpointDetails";
|
||||
import SuiteDetails from '@/views/SuiteDetails';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -10,9 +11,14 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/endpoints/:key',
|
||||
name: 'Details',
|
||||
component: Details,
|
||||
name: 'EndpointDetails',
|
||||
component: EndpointDetails,
|
||||
},
|
||||
{
|
||||
path: '/suites/:key',
|
||||
name: 'SuiteDetails',
|
||||
component: SuiteDetails
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
17
web/app/src/utils/format.js
Normal file
17
web/app/src/utils/format.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Formats a duration from nanoseconds to a human-readable string
|
||||
* @param {number} duration - Duration in nanoseconds
|
||||
* @returns {string} Formatted duration string (e.g., "123ms", "1.23s")
|
||||
*/
|
||||
export const formatDuration = (duration) => {
|
||||
if (!duration && duration !== 0) return 'N/A'
|
||||
|
||||
// Convert nanoseconds to milliseconds
|
||||
const durationMs = duration / 1000000
|
||||
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs.toFixed(0)}ms`
|
||||
} else {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`
|
||||
}
|
||||
}
|
||||
52
web/app/src/utils/time.js
Normal file
52
web/app/src/utils/time.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Generates a human-readable relative time string (e.g., "2 hours ago")
|
||||
* @param {string|Date} timestamp - The timestamp to convert
|
||||
* @returns {string} Relative time string
|
||||
*/
|
||||
export const generatePrettyTimeAgo = (timestamp) => {
|
||||
let differenceInMs = new Date().getTime() - new Date(timestamp).getTime();
|
||||
if (differenceInMs < 500) {
|
||||
return "now";
|
||||
}
|
||||
if (differenceInMs > 3 * 86400000) { // If it was more than 3 days ago, we'll display the number of days ago
|
||||
let days = (differenceInMs / 86400000).toFixed(0);
|
||||
return days + " day" + (days !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
if (differenceInMs > 3600000) { // If it was more than 1h ago, display the number of hours ago
|
||||
let hours = (differenceInMs / 3600000).toFixed(0);
|
||||
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
if (differenceInMs > 60000) {
|
||||
let minutes = (differenceInMs / 60000).toFixed(0);
|
||||
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
let seconds = (differenceInMs / 1000).toFixed(0);
|
||||
return seconds + " second" + (seconds !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a pretty time difference string between two timestamps
|
||||
* @param {string|Date} start - Start timestamp
|
||||
* @param {string|Date} end - End timestamp
|
||||
* @returns {string} Time difference string
|
||||
*/
|
||||
export const generatePrettyTimeDifference = (start, end) => {
|
||||
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
|
||||
return minutes + (minutes === 1 ? ' minute' : ' minutes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp into YYYY-MM-DD HH:mm:ss format
|
||||
* @param {string|Date} timestamp - The timestamp to format
|
||||
* @returns {string} Formatted timestamp
|
||||
*/
|
||||
export const prettifyTimestamp = (timestamp) => {
|
||||
let date = new Date(timestamp);
|
||||
let YYYY = date.getFullYear();
|
||||
let MM = ((date.getMonth() + 1) < 10 ? "0" : "") + "" + (date.getMonth() + 1);
|
||||
let DD = ((date.getDate()) < 10 ? "0" : "") + "" + (date.getDate());
|
||||
let hh = ((date.getHours()) < 10 ? "0" : "") + "" + (date.getHours());
|
||||
let mm = ((date.getMinutes()) < 10 ? "0" : "") + "" + (date.getMinutes());
|
||||
let ss = ((date.getSeconds()) < 10 ? "0" : "") + "" + (date.getSeconds());
|
||||
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
|
||||
}
|
||||
@@ -207,7 +207,7 @@ import Settings from '@/components/Settings.vue'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { SERVER_URL } from '@/main.js'
|
||||
import { helper } from '@/mixins/helper'
|
||||
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -290,7 +290,7 @@ const lastCheckTime = computed(() => {
|
||||
if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {
|
||||
return 'Never'
|
||||
}
|
||||
return helper.methods.generatePrettyTimeAgo(currentStatus.value.results[currentStatus.value.results.length - 1].timestamp)
|
||||
return generatePrettyTimeAgo(currentStatus.value.results[currentStatus.value.results.length - 1].timestamp)
|
||||
})
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ const fetchData = async () => {
|
||||
event.fancyText = 'Endpoint became healthy'
|
||||
} else if (event.type === 'UNHEALTHY') {
|
||||
if (nextEvent) {
|
||||
event.fancyText = 'Endpoint was unhealthy for ' + helper.methods.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
|
||||
event.fancyText = 'Endpoint was unhealthy for ' + generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
|
||||
} else {
|
||||
event.fancyText = 'Endpoint became unhealthy'
|
||||
}
|
||||
@@ -336,7 +336,7 @@ const fetchData = async () => {
|
||||
event.fancyText = 'Monitoring started'
|
||||
}
|
||||
}
|
||||
event.fancyTimeAgo = helper.methods.generatePrettyTimeAgo(event.timestamp)
|
||||
event.fancyTimeAgo = generatePrettyTimeAgo(event.timestamp)
|
||||
processedEvents.push(event)
|
||||
}
|
||||
}
|
||||
@@ -39,20 +39,20 @@
|
||||
<Loading size="lg" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredEndpoints.length === 0" class="text-center py-20">
|
||||
<div v-else-if="filteredEndpoints.length === 0 && filteredSuites.length === 0" class="text-center py-20">
|
||||
<AlertCircle class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No endpoints found</h3>
|
||||
<h3 class="text-lg font-semibold mb-2">No endpoints or suites found</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{{ searchQuery || showOnlyFailing || showRecentFailures
|
||||
? 'Try adjusting your filters'
|
||||
: 'No endpoints are configured' }}
|
||||
: 'No endpoints or suites are configured' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Grouped view -->
|
||||
<div v-if="groupByGroup" class="space-y-6">
|
||||
<div v-for="(endpoints, group) in paginatedEndpoints" :key="group" class="endpoint-group border rounded-lg overflow-hidden">
|
||||
<div v-for="(items, group) in combinedGroups" :key="group" class="endpoint-group border rounded-lg overflow-hidden">
|
||||
<!-- Group Header -->
|
||||
<div
|
||||
@click="toggleGroupCollapse(group)"
|
||||
@@ -64,9 +64,9 @@
|
||||
<h2 class="text-xl font-semibold text-foreground">{{ group }}</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="calculateUnhealthyCount(endpoints) > 0"
|
||||
<span v-if="calculateUnhealthyCount(items.endpoints) + calculateFailingSuitesCount(items.suites) > 0"
|
||||
class="bg-red-600 text-white px-2 py-1 rounded-full text-sm font-medium">
|
||||
{{ calculateUnhealthyCount(endpoints) }}
|
||||
{{ calculateUnhealthyCount(items.endpoints) + calculateFailingSuitesCount(items.suites) }}
|
||||
</span>
|
||||
<CheckCircle v-else class="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
@@ -74,30 +74,68 @@
|
||||
|
||||
<!-- Group Content -->
|
||||
<div v-if="uncollapsedGroups.has(group)" class="endpoint-group-content p-4">
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EndpointCard
|
||||
v-for="endpoint in endpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
<!-- Suites Section -->
|
||||
<div v-if="items.suites.length > 0" class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Suites</h3>
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SuiteCard
|
||||
v-for="suite in items.suites"
|
||||
:key="suite.key"
|
||||
:suite="suite"
|
||||
:maxResults="50"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoints Section -->
|
||||
<div v-if="items.endpoints.length > 0">
|
||||
<h3 v-if="items.suites.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Endpoints</h3>
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EndpointCard
|
||||
v-for="endpoint in items.endpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular view -->
|
||||
<div v-else class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EndpointCard
|
||||
v-for="endpoint in paginatedEndpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
<div v-else>
|
||||
<!-- Suites Section -->
|
||||
<div v-if="filteredSuites.length > 0" class="mb-6">
|
||||
<h2 class="text-lg font-semibold text-foreground mb-3">Suites</h2>
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SuiteCard
|
||||
v-for="suite in paginatedSuites"
|
||||
:key="suite.key"
|
||||
:suite="suite"
|
||||
:maxResults="50"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoints Section -->
|
||||
<div v-if="filteredEndpoints.length > 0">
|
||||
<h2 v-if="filteredSuites.length > 0" class="text-lg font-semibold text-foreground mb-3">Endpoints</h2>
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EndpointCard
|
||||
v-for="endpoint in paginatedEndpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!groupByGroup && totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
|
||||
@@ -144,6 +182,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { Activity, Timer, RefreshCw, AlertCircle, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, CheckCircle } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import SuiteCard from '@/components/SuiteCard.vue'
|
||||
import SearchBar from '@/components/SearchBar.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
@@ -160,6 +199,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
const endpointStatuses = ref([])
|
||||
const suiteStatuses = ref([])
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 96
|
||||
@@ -215,8 +255,51 @@ const filteredEndpoints = computed(() => {
|
||||
return filtered
|
||||
})
|
||||
|
||||
const filteredSuites = computed(() => {
|
||||
let filtered = [...suiteStatuses.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(suite =>
|
||||
suite.name.toLowerCase().includes(query) ||
|
||||
(suite.group && suite.group.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
if (showOnlyFailing.value) {
|
||||
filtered = filtered.filter(suite => {
|
||||
if (!suite.results || suite.results.length === 0) return false
|
||||
return !suite.results[suite.results.length - 1].success
|
||||
})
|
||||
}
|
||||
|
||||
if (showRecentFailures.value) {
|
||||
filtered = filtered.filter(suite => {
|
||||
if (!suite.results || suite.results.length === 0) return false
|
||||
return suite.results.some(result => !result.success)
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by health if selected
|
||||
if (sortBy.value === 'health') {
|
||||
filtered.sort((a, b) => {
|
||||
const aHealthy = a.results && a.results.length > 0 && a.results[a.results.length - 1].success
|
||||
const bHealthy = b.results && b.results.length > 0 && b.results[b.results.length - 1].success
|
||||
|
||||
// Unhealthy first
|
||||
if (!aHealthy && bHealthy) return -1
|
||||
if (aHealthy && !bHealthy) return 1
|
||||
|
||||
// Then sort by name
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredEndpoints.value.length / itemsPerPage)
|
||||
return Math.ceil((filteredEndpoints.value.length + filteredSuites.value.length) / itemsPerPage)
|
||||
})
|
||||
|
||||
const groupedEndpoints = computed(() => {
|
||||
@@ -248,6 +331,46 @@ const groupedEndpoints = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const combinedGroups = computed(() => {
|
||||
if (!groupByGroup.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const combined = {}
|
||||
|
||||
// Add endpoints
|
||||
filteredEndpoints.value.forEach(endpoint => {
|
||||
const group = endpoint.group || 'No Group'
|
||||
if (!combined[group]) {
|
||||
combined[group] = { endpoints: [], suites: [] }
|
||||
}
|
||||
combined[group].endpoints.push(endpoint)
|
||||
})
|
||||
|
||||
// Add suites
|
||||
filteredSuites.value.forEach(suite => {
|
||||
const group = suite.group || 'No Group'
|
||||
if (!combined[group]) {
|
||||
combined[group] = { endpoints: [], suites: [] }
|
||||
}
|
||||
combined[group].suites.push(suite)
|
||||
})
|
||||
|
||||
// Sort groups alphabetically, with 'No Group' at the end
|
||||
const sortedGroups = Object.keys(combined).sort((a, b) => {
|
||||
if (a === 'No Group') return 1
|
||||
if (b === 'No Group') return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
const result = {}
|
||||
sortedGroups.forEach(group => {
|
||||
result[group] = combined[group]
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const paginatedEndpoints = computed(() => {
|
||||
if (groupByGroup.value) {
|
||||
// When grouping, we don't paginate
|
||||
@@ -259,6 +382,17 @@ const paginatedEndpoints = computed(() => {
|
||||
return filteredEndpoints.value.slice(start, end)
|
||||
})
|
||||
|
||||
const paginatedSuites = computed(() => {
|
||||
if (groupByGroup.value) {
|
||||
// When grouping, we don't paginate
|
||||
return filteredSuites.value
|
||||
}
|
||||
|
||||
const start = (currentPage.value - 1) * itemsPerPage
|
||||
const end = start + itemsPerPage
|
||||
return filteredSuites.value.slice(start, end)
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const maxVisible = 5
|
||||
@@ -278,42 +412,31 @@ const visiblePages = computed(() => {
|
||||
|
||||
const fetchData = async () => {
|
||||
// Don't show loading state on refresh to prevent UI flicker
|
||||
const isInitialLoad = endpointStatuses.value.length === 0
|
||||
const isInitialLoad = endpointStatuses.value.length === 0 && suiteStatuses.value.length === 0
|
||||
if (isInitialLoad) {
|
||||
loading.value = true
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
|
||||
// Fetch endpoints
|
||||
const endpointResponse = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (response.status === 200) {
|
||||
const data = await response.json()
|
||||
// If this is the initial load, just set the data
|
||||
if (isInitialLoad) {
|
||||
endpointStatuses.value = data
|
||||
} else {
|
||||
// Check if endpoints have been added or removed
|
||||
const currentKeys = new Set(endpointStatuses.value.map(ep => ep.key))
|
||||
const newKeys = new Set(data.map(ep => ep.key))
|
||||
const hasAdditions = data.some(ep => !currentKeys.has(ep.key))
|
||||
const hasRemovals = endpointStatuses.value.some(ep => !newKeys.has(ep.key))
|
||||
if (hasAdditions || hasRemovals) {
|
||||
// Endpoints have changed, reset the array to maintain proper order
|
||||
endpointStatuses.value = data
|
||||
} else {
|
||||
// Only statuses/results have changed, update in place to preserve scroll
|
||||
const endpointMap = new Map(data.map(ep => [ep.key, ep]))
|
||||
endpointStatuses.value.forEach((endpoint, index) => {
|
||||
const updated = endpointMap.get(endpoint.key)
|
||||
if (updated) {
|
||||
// Update in place to preserve Vue's reactivity and scroll position
|
||||
Object.assign(endpointStatuses.value[index], updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (endpointResponse.status === 200) {
|
||||
const data = await endpointResponse.json()
|
||||
endpointStatuses.value = data
|
||||
} else {
|
||||
console.error('[Home][fetchData] Error:', await response.text())
|
||||
console.error('[Home][fetchData] Error fetching endpoints:', await endpointResponse.text())
|
||||
}
|
||||
|
||||
// Fetch suites
|
||||
const suiteResponse = await fetch(`${SERVER_URL}/api/v1/suites/statuses?page=1&pageSize=100`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (suiteResponse.status === 200) {
|
||||
const suiteData = await suiteResponse.json()
|
||||
suiteStatuses.value = suiteData
|
||||
} else {
|
||||
console.error('[Home][fetchData] Error fetching suites:', await suiteResponse.text())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Home][fetchData] Error:', error)
|
||||
@@ -355,6 +478,13 @@ const calculateUnhealthyCount = (endpoints) => {
|
||||
}).length
|
||||
}
|
||||
|
||||
const calculateFailingSuitesCount = (suites) => {
|
||||
return suites.filter(suite => {
|
||||
if (!suite.results || suite.results.length === 0) return false
|
||||
return !suite.results[suite.results.length - 1].success
|
||||
}).length
|
||||
}
|
||||
|
||||
const toggleGroupCollapse = (groupName) => {
|
||||
if (uncollapsedGroups.value.has(groupName)) {
|
||||
uncollapsedGroups.value.delete(groupName)
|
||||
|
||||
334
web/app/src/views/SuiteDetails.vue
Normal file
334
web/app/src/views/SuiteDetails.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="suite-details-container bg-background min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<!-- Back button and header -->
|
||||
<div class="mb-6">
|
||||
<Button variant="ghost" size="sm" @click="goBack" class="mb-4">
|
||||
<ArrowLeft class="h-4 w-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ suite?.name || 'Loading...' }}</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
<span v-if="suite?.group">{{ suite.group }} • </span>
|
||||
<span v-if="latestResult">
|
||||
{{ selectedResult && selectedResult !== sortedResults[0] ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge v-if="latestResult" :status="latestResult.success ? 'healthy' : 'unhealthy'" />
|
||||
<Button variant="ghost" size="icon" @click="refreshData" title="Refresh">
|
||||
<RefreshCw class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<Loading size="lg" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!suite" class="text-center py-20">
|
||||
<AlertCircle class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Suite not found</h3>
|
||||
<p class="text-muted-foreground">The requested suite could not be found.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Latest Execution -->
|
||||
<Card v-if="latestResult">
|
||||
<CardHeader>
|
||||
<CardTitle>Latest Execution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<!-- Execution stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Status</p>
|
||||
<p class="text-lg font-medium">{{ latestResult.success ? 'Success' : 'Failed' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Duration</p>
|
||||
<p class="text-lg font-medium">{{ formatDuration(latestResult.duration) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Endpoints</p>
|
||||
<p class="text-lg font-medium">{{ latestResult.endpointResults?.length || 0 }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Success Rate</p>
|
||||
<p class="text-lg font-medium">{{ calculateSuccessRate(latestResult) }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Execution Flow -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Execution Flow</h3>
|
||||
<SequentialFlowDiagram
|
||||
:flow-steps="flowSteps"
|
||||
:progress-percentage="executionProgress"
|
||||
:completed-steps="completedStepsCount"
|
||||
:total-steps="flowSteps.length"
|
||||
@step-selected="onStepSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Errors -->
|
||||
<div v-if="latestResult.errors && latestResult.errors.length > 0" class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-3 text-red-500">Suite Errors</h3>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(error, index) in latestResult.errors"
|
||||
:key="index"
|
||||
class="bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300 p-3 rounded-md text-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Execution History -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Execution History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="sortedResults.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="(result, index) in sortedResults"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
@click="selectedResult = result"
|
||||
:class="{ 'bg-accent': selectedResult === result }"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<StatusBadge :status="result.success ? 'healthy' : 'unhealthy'" size="sm" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ formatTimestamp(result.timestamp) }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ result.endpointResults?.length || 0 }} endpoints • {{ formatDuration(result.duration) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight class="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
No execution history available
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Settings @refreshData="fetchData" />
|
||||
|
||||
<!-- Step Details Modal -->
|
||||
<StepDetailsModal
|
||||
v-if="selectedStep"
|
||||
:step="selectedStep"
|
||||
:index="selectedStepIndex"
|
||||
@close="selectedStep = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, RefreshCw, AlertCircle, ChevronRight } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import SequentialFlowDiagram from '@/components/SequentialFlowDiagram.vue'
|
||||
import StepDetailsModal from '@/components/StepDetailsModal.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { generatePrettyTimeAgo } from '@/utils/time'
|
||||
import { SERVER_URL } from '@/main'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const suite = ref(null)
|
||||
const selectedResult = ref(null)
|
||||
const selectedStep = ref(null)
|
||||
const selectedStepIndex = ref(0)
|
||||
|
||||
// Computed properties
|
||||
const sortedResults = computed(() => {
|
||||
if (!suite.value || !suite.value.results || suite.value.results.length === 0) {
|
||||
return []
|
||||
}
|
||||
// Sort results by timestamp in descending order (most recent first)
|
||||
return [...suite.value.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
})
|
||||
|
||||
const latestResult = computed(() => {
|
||||
if (!suite.value || !suite.value.results || suite.value.results.length === 0) {
|
||||
return null
|
||||
}
|
||||
return selectedResult.value || sortedResults.value[0]
|
||||
})
|
||||
|
||||
// Methods
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/suites/${route.params.key}/statuses`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json()
|
||||
suite.value = data
|
||||
if (data.results && data.results.length > 0 && !selectedResult.value) {
|
||||
// Sort results by timestamp to get the most recent one
|
||||
const sorted = [...data.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
selectedResult.value = sorted[0]
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
suite.value = null
|
||||
} else {
|
||||
console.error('[SuiteDetails][fetchData] Error:', await response.text())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SuiteDetails][fetchData] Error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const formatRelativeTime = (timestamp) => {
|
||||
return generatePrettyTimeAgo(timestamp)
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatDuration = (duration) => {
|
||||
if (!duration && duration !== 0) return 'N/A'
|
||||
|
||||
// Convert nanoseconds to milliseconds
|
||||
const durationMs = duration / 1000000
|
||||
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs.toFixed(0)}ms`
|
||||
} else {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`
|
||||
}
|
||||
}
|
||||
|
||||
const calculateSuccessRate = (result) => {
|
||||
if (!result || !result.endpointResults || result.endpointResults.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const successful = result.endpointResults.filter(e => e.success).length
|
||||
return Math.round((successful / result.endpointResults.length) * 100)
|
||||
}
|
||||
|
||||
// Flow diagram computed properties
|
||||
const flowSteps = computed(() => {
|
||||
if (!latestResult.value || !latestResult.value.endpointResults) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results = latestResult.value.endpointResults
|
||||
|
||||
return results.map((result, index) => {
|
||||
const endpoint = suite.value?.endpoints?.[index]
|
||||
const nextResult = results[index + 1]
|
||||
|
||||
// Determine if this is an always-run endpoint by checking execution pattern
|
||||
// If a previous step failed but this one still executed, it must be always-run
|
||||
let isAlwaysRun = false
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (!results[i].success) {
|
||||
// A previous step failed, but we're still executing, so this must be always-run
|
||||
isAlwaysRun = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: endpoint?.name || result.name || `Step ${index + 1}`,
|
||||
endpoint: endpoint,
|
||||
result: result,
|
||||
status: determineStepStatus(result, endpoint),
|
||||
duration: result.duration || 0,
|
||||
isAlwaysRun: isAlwaysRun,
|
||||
errors: result.errors || [],
|
||||
nextStepStatus: nextResult ? determineStepStatus(nextResult, suite.value?.endpoints?.[index + 1]) : null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const completedStepsCount = computed(() => {
|
||||
return flowSteps.value.filter(step => step.status === 'success').length
|
||||
})
|
||||
|
||||
const executionProgress = computed(() => {
|
||||
if (!flowSteps.value.length) return 0
|
||||
return Math.round((completedStepsCount.value / flowSteps.value.length) * 100)
|
||||
})
|
||||
|
||||
|
||||
|
||||
// Helper functions
|
||||
const determineStepStatus = (result) => {
|
||||
if (!result) return 'not-started'
|
||||
|
||||
// Check if step was skipped
|
||||
if (result.conditionResults && result.conditionResults.some(c => c.condition.includes('SKIP'))) {
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
// Check if step failed but is always-run (still shows as failed but executed)
|
||||
if (!result.success) {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
return 'success'
|
||||
}
|
||||
|
||||
|
||||
// Event handlers
|
||||
const onStepSelected = (step, index) => {
|
||||
selectedStep.value = step
|
||||
selectedStepIndex.value = index
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.suite-details-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user