1
0
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:
TwiN
2025-09-05 15:39:12 -04:00
committed by GitHub
parent 10cabb9dde
commit d668a14703
74 changed files with 7513 additions and 652 deletions

View File

@@ -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 = () => {

View 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>

View 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>

View 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>

View 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>

View File

@@ -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

View File

@@ -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;
},
}
}

View File

@@ -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({

View 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
View 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;
}

View File

@@ -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)
}
}

View File

@@ -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)

View 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