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>