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:
@@ -207,7 +207,7 @@ import Settings from '@/components/Settings.vue'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { SERVER_URL } from '@/main.js'
|
||||
import { helper } from '@/mixins/helper'
|
||||
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -290,7 +290,7 @@ const lastCheckTime = computed(() => {
|
||||
if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {
|
||||
return 'Never'
|
||||
}
|
||||
return helper.methods.generatePrettyTimeAgo(currentStatus.value.results[currentStatus.value.results.length - 1].timestamp)
|
||||
return generatePrettyTimeAgo(currentStatus.value.results[currentStatus.value.results.length - 1].timestamp)
|
||||
})
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ const fetchData = async () => {
|
||||
event.fancyText = 'Endpoint became healthy'
|
||||
} else if (event.type === 'UNHEALTHY') {
|
||||
if (nextEvent) {
|
||||
event.fancyText = 'Endpoint was unhealthy for ' + helper.methods.generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
|
||||
event.fancyText = 'Endpoint was unhealthy for ' + generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
|
||||
} else {
|
||||
event.fancyText = 'Endpoint became unhealthy'
|
||||
}
|
||||
@@ -336,7 +336,7 @@ const fetchData = async () => {
|
||||
event.fancyText = 'Monitoring started'
|
||||
}
|
||||
}
|
||||
event.fancyTimeAgo = helper.methods.generatePrettyTimeAgo(event.timestamp)
|
||||
event.fancyTimeAgo = generatePrettyTimeAgo(event.timestamp)
|
||||
processedEvents.push(event)
|
||||
}
|
||||
}
|
||||
@@ -39,20 +39,20 @@
|
||||
<Loading size="lg" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredEndpoints.length === 0" class="text-center py-20">
|
||||
<div v-else-if="filteredEndpoints.length === 0 && filteredSuites.length === 0" class="text-center py-20">
|
||||
<AlertCircle class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No endpoints found</h3>
|
||||
<h3 class="text-lg font-semibold mb-2">No endpoints or suites found</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{{ searchQuery || showOnlyFailing || showRecentFailures
|
||||
? 'Try adjusting your filters'
|
||||
: 'No endpoints are configured' }}
|
||||
: 'No endpoints or suites are configured' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Grouped view -->
|
||||
<div v-if="groupByGroup" class="space-y-6">
|
||||
<div v-for="(endpoints, group) in paginatedEndpoints" :key="group" class="endpoint-group border rounded-lg overflow-hidden">
|
||||
<div v-for="(items, group) in combinedGroups" :key="group" class="endpoint-group border rounded-lg overflow-hidden">
|
||||
<!-- Group Header -->
|
||||
<div
|
||||
@click="toggleGroupCollapse(group)"
|
||||
@@ -64,9 +64,9 @@
|
||||
<h2 class="text-xl font-semibold text-foreground">{{ group }}</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="calculateUnhealthyCount(endpoints) > 0"
|
||||
<span v-if="calculateUnhealthyCount(items.endpoints) + calculateFailingSuitesCount(items.suites) > 0"
|
||||
class="bg-red-600 text-white px-2 py-1 rounded-full text-sm font-medium">
|
||||
{{ calculateUnhealthyCount(endpoints) }}
|
||||
{{ calculateUnhealthyCount(items.endpoints) + calculateFailingSuitesCount(items.suites) }}
|
||||
</span>
|
||||
<CheckCircle v-else class="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
@@ -74,30 +74,68 @@
|
||||
|
||||
<!-- Group Content -->
|
||||
<div v-if="uncollapsedGroups.has(group)" class="endpoint-group-content p-4">
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EndpointCard
|
||||
v-for="endpoint in endpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
<!-- Suites Section -->
|
||||
<div v-if="items.suites.length > 0" class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Suites</h3>
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SuiteCard
|
||||
v-for="suite in items.suites"
|
||||
:key="suite.key"
|
||||
:suite="suite"
|
||||
:maxResults="50"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoints Section -->
|
||||
<div v-if="items.endpoints.length > 0">
|
||||
<h3 v-if="items.suites.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Endpoints</h3>
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EndpointCard
|
||||
v-for="endpoint in items.endpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular view -->
|
||||
<div v-else class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EndpointCard
|
||||
v-for="endpoint in paginatedEndpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
<div v-else>
|
||||
<!-- Suites Section -->
|
||||
<div v-if="filteredSuites.length > 0" class="mb-6">
|
||||
<h2 class="text-lg font-semibold text-foreground mb-3">Suites</h2>
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SuiteCard
|
||||
v-for="suite in paginatedSuites"
|
||||
:key="suite.key"
|
||||
:suite="suite"
|
||||
:maxResults="50"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoints Section -->
|
||||
<div v-if="filteredEndpoints.length > 0">
|
||||
<h2 v-if="filteredSuites.length > 0" class="text-lg font-semibold text-foreground mb-3">Endpoints</h2>
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EndpointCard
|
||||
v-for="endpoint in paginatedEndpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!groupByGroup && totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
|
||||
@@ -144,6 +182,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { Activity, Timer, RefreshCw, AlertCircle, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, CheckCircle } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import SuiteCard from '@/components/SuiteCard.vue'
|
||||
import SearchBar from '@/components/SearchBar.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
@@ -160,6 +199,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
const endpointStatuses = ref([])
|
||||
const suiteStatuses = ref([])
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 96
|
||||
@@ -215,8 +255,51 @@ const filteredEndpoints = computed(() => {
|
||||
return filtered
|
||||
})
|
||||
|
||||
const filteredSuites = computed(() => {
|
||||
let filtered = [...suiteStatuses.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(suite =>
|
||||
suite.name.toLowerCase().includes(query) ||
|
||||
(suite.group && suite.group.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
if (showOnlyFailing.value) {
|
||||
filtered = filtered.filter(suite => {
|
||||
if (!suite.results || suite.results.length === 0) return false
|
||||
return !suite.results[suite.results.length - 1].success
|
||||
})
|
||||
}
|
||||
|
||||
if (showRecentFailures.value) {
|
||||
filtered = filtered.filter(suite => {
|
||||
if (!suite.results || suite.results.length === 0) return false
|
||||
return suite.results.some(result => !result.success)
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by health if selected
|
||||
if (sortBy.value === 'health') {
|
||||
filtered.sort((a, b) => {
|
||||
const aHealthy = a.results && a.results.length > 0 && a.results[a.results.length - 1].success
|
||||
const bHealthy = b.results && b.results.length > 0 && b.results[b.results.length - 1].success
|
||||
|
||||
// Unhealthy first
|
||||
if (!aHealthy && bHealthy) return -1
|
||||
if (aHealthy && !bHealthy) return 1
|
||||
|
||||
// Then sort by name
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredEndpoints.value.length / itemsPerPage)
|
||||
return Math.ceil((filteredEndpoints.value.length + filteredSuites.value.length) / itemsPerPage)
|
||||
})
|
||||
|
||||
const groupedEndpoints = computed(() => {
|
||||
@@ -248,6 +331,46 @@ const groupedEndpoints = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const combinedGroups = computed(() => {
|
||||
if (!groupByGroup.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const combined = {}
|
||||
|
||||
// Add endpoints
|
||||
filteredEndpoints.value.forEach(endpoint => {
|
||||
const group = endpoint.group || 'No Group'
|
||||
if (!combined[group]) {
|
||||
combined[group] = { endpoints: [], suites: [] }
|
||||
}
|
||||
combined[group].endpoints.push(endpoint)
|
||||
})
|
||||
|
||||
// Add suites
|
||||
filteredSuites.value.forEach(suite => {
|
||||
const group = suite.group || 'No Group'
|
||||
if (!combined[group]) {
|
||||
combined[group] = { endpoints: [], suites: [] }
|
||||
}
|
||||
combined[group].suites.push(suite)
|
||||
})
|
||||
|
||||
// Sort groups alphabetically, with 'No Group' at the end
|
||||
const sortedGroups = Object.keys(combined).sort((a, b) => {
|
||||
if (a === 'No Group') return 1
|
||||
if (b === 'No Group') return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
const result = {}
|
||||
sortedGroups.forEach(group => {
|
||||
result[group] = combined[group]
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const paginatedEndpoints = computed(() => {
|
||||
if (groupByGroup.value) {
|
||||
// When grouping, we don't paginate
|
||||
@@ -259,6 +382,17 @@ const paginatedEndpoints = computed(() => {
|
||||
return filteredEndpoints.value.slice(start, end)
|
||||
})
|
||||
|
||||
const paginatedSuites = computed(() => {
|
||||
if (groupByGroup.value) {
|
||||
// When grouping, we don't paginate
|
||||
return filteredSuites.value
|
||||
}
|
||||
|
||||
const start = (currentPage.value - 1) * itemsPerPage
|
||||
const end = start + itemsPerPage
|
||||
return filteredSuites.value.slice(start, end)
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const maxVisible = 5
|
||||
@@ -278,42 +412,31 @@ const visiblePages = computed(() => {
|
||||
|
||||
const fetchData = async () => {
|
||||
// Don't show loading state on refresh to prevent UI flicker
|
||||
const isInitialLoad = endpointStatuses.value.length === 0
|
||||
const isInitialLoad = endpointStatuses.value.length === 0 && suiteStatuses.value.length === 0
|
||||
if (isInitialLoad) {
|
||||
loading.value = true
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
|
||||
// Fetch endpoints
|
||||
const endpointResponse = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (response.status === 200) {
|
||||
const data = await response.json()
|
||||
// If this is the initial load, just set the data
|
||||
if (isInitialLoad) {
|
||||
endpointStatuses.value = data
|
||||
} else {
|
||||
// Check if endpoints have been added or removed
|
||||
const currentKeys = new Set(endpointStatuses.value.map(ep => ep.key))
|
||||
const newKeys = new Set(data.map(ep => ep.key))
|
||||
const hasAdditions = data.some(ep => !currentKeys.has(ep.key))
|
||||
const hasRemovals = endpointStatuses.value.some(ep => !newKeys.has(ep.key))
|
||||
if (hasAdditions || hasRemovals) {
|
||||
// Endpoints have changed, reset the array to maintain proper order
|
||||
endpointStatuses.value = data
|
||||
} else {
|
||||
// Only statuses/results have changed, update in place to preserve scroll
|
||||
const endpointMap = new Map(data.map(ep => [ep.key, ep]))
|
||||
endpointStatuses.value.forEach((endpoint, index) => {
|
||||
const updated = endpointMap.get(endpoint.key)
|
||||
if (updated) {
|
||||
// Update in place to preserve Vue's reactivity and scroll position
|
||||
Object.assign(endpointStatuses.value[index], updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (endpointResponse.status === 200) {
|
||||
const data = await endpointResponse.json()
|
||||
endpointStatuses.value = data
|
||||
} else {
|
||||
console.error('[Home][fetchData] Error:', await response.text())
|
||||
console.error('[Home][fetchData] Error fetching endpoints:', await endpointResponse.text())
|
||||
}
|
||||
|
||||
// Fetch suites
|
||||
const suiteResponse = await fetch(`${SERVER_URL}/api/v1/suites/statuses?page=1&pageSize=100`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (suiteResponse.status === 200) {
|
||||
const suiteData = await suiteResponse.json()
|
||||
suiteStatuses.value = suiteData
|
||||
} else {
|
||||
console.error('[Home][fetchData] Error fetching suites:', await suiteResponse.text())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Home][fetchData] Error:', error)
|
||||
@@ -355,6 +478,13 @@ const calculateUnhealthyCount = (endpoints) => {
|
||||
}).length
|
||||
}
|
||||
|
||||
const calculateFailingSuitesCount = (suites) => {
|
||||
return suites.filter(suite => {
|
||||
if (!suite.results || suite.results.length === 0) return false
|
||||
return !suite.results[suite.results.length - 1].success
|
||||
}).length
|
||||
}
|
||||
|
||||
const toggleGroupCollapse = (groupName) => {
|
||||
if (uncollapsedGroups.value.has(groupName)) {
|
||||
uncollapsedGroups.value.delete(groupName)
|
||||
|
||||
334
web/app/src/views/SuiteDetails.vue
Normal file
334
web/app/src/views/SuiteDetails.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="suite-details-container bg-background min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<!-- Back button and header -->
|
||||
<div class="mb-6">
|
||||
<Button variant="ghost" size="sm" @click="goBack" class="mb-4">
|
||||
<ArrowLeft class="h-4 w-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ suite?.name || 'Loading...' }}</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
<span v-if="suite?.group">{{ suite.group }} • </span>
|
||||
<span v-if="latestResult">
|
||||
{{ selectedResult && selectedResult !== sortedResults[0] ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge v-if="latestResult" :status="latestResult.success ? 'healthy' : 'unhealthy'" />
|
||||
<Button variant="ghost" size="icon" @click="refreshData" title="Refresh">
|
||||
<RefreshCw class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<Loading size="lg" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!suite" class="text-center py-20">
|
||||
<AlertCircle class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Suite not found</h3>
|
||||
<p class="text-muted-foreground">The requested suite could not be found.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Latest Execution -->
|
||||
<Card v-if="latestResult">
|
||||
<CardHeader>
|
||||
<CardTitle>Latest Execution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<!-- Execution stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Status</p>
|
||||
<p class="text-lg font-medium">{{ latestResult.success ? 'Success' : 'Failed' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Duration</p>
|
||||
<p class="text-lg font-medium">{{ formatDuration(latestResult.duration) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Endpoints</p>
|
||||
<p class="text-lg font-medium">{{ latestResult.endpointResults?.length || 0 }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Success Rate</p>
|
||||
<p class="text-lg font-medium">{{ calculateSuccessRate(latestResult) }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Execution Flow -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Execution Flow</h3>
|
||||
<SequentialFlowDiagram
|
||||
:flow-steps="flowSteps"
|
||||
:progress-percentage="executionProgress"
|
||||
:completed-steps="completedStepsCount"
|
||||
:total-steps="flowSteps.length"
|
||||
@step-selected="onStepSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Errors -->
|
||||
<div v-if="latestResult.errors && latestResult.errors.length > 0" class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-3 text-red-500">Suite Errors</h3>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(error, index) in latestResult.errors"
|
||||
:key="index"
|
||||
class="bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300 p-3 rounded-md text-sm"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Execution History -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Execution History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="sortedResults.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="(result, index) in sortedResults"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
@click="selectedResult = result"
|
||||
:class="{ 'bg-accent': selectedResult === result }"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<StatusBadge :status="result.success ? 'healthy' : 'unhealthy'" size="sm" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ formatTimestamp(result.timestamp) }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ result.endpointResults?.length || 0 }} endpoints • {{ formatDuration(result.duration) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight class="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
No execution history available
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Settings @refreshData="fetchData" />
|
||||
|
||||
<!-- Step Details Modal -->
|
||||
<StepDetailsModal
|
||||
v-if="selectedStep"
|
||||
:step="selectedStep"
|
||||
:index="selectedStepIndex"
|
||||
@close="selectedStep = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, RefreshCw, AlertCircle, ChevronRight } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import SequentialFlowDiagram from '@/components/SequentialFlowDiagram.vue'
|
||||
import StepDetailsModal from '@/components/StepDetailsModal.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { generatePrettyTimeAgo } from '@/utils/time'
|
||||
import { SERVER_URL } from '@/main'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const suite = ref(null)
|
||||
const selectedResult = ref(null)
|
||||
const selectedStep = ref(null)
|
||||
const selectedStepIndex = ref(0)
|
||||
|
||||
// Computed properties
|
||||
const sortedResults = computed(() => {
|
||||
if (!suite.value || !suite.value.results || suite.value.results.length === 0) {
|
||||
return []
|
||||
}
|
||||
// Sort results by timestamp in descending order (most recent first)
|
||||
return [...suite.value.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
})
|
||||
|
||||
const latestResult = computed(() => {
|
||||
if (!suite.value || !suite.value.results || suite.value.results.length === 0) {
|
||||
return null
|
||||
}
|
||||
return selectedResult.value || sortedResults.value[0]
|
||||
})
|
||||
|
||||
// Methods
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/suites/${route.params.key}/statuses`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json()
|
||||
suite.value = data
|
||||
if (data.results && data.results.length > 0 && !selectedResult.value) {
|
||||
// Sort results by timestamp to get the most recent one
|
||||
const sorted = [...data.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
selectedResult.value = sorted[0]
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
suite.value = null
|
||||
} else {
|
||||
console.error('[SuiteDetails][fetchData] Error:', await response.text())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SuiteDetails][fetchData] Error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const formatRelativeTime = (timestamp) => {
|
||||
return generatePrettyTimeAgo(timestamp)
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatDuration = (duration) => {
|
||||
if (!duration && duration !== 0) return 'N/A'
|
||||
|
||||
// Convert nanoseconds to milliseconds
|
||||
const durationMs = duration / 1000000
|
||||
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs.toFixed(0)}ms`
|
||||
} else {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`
|
||||
}
|
||||
}
|
||||
|
||||
const calculateSuccessRate = (result) => {
|
||||
if (!result || !result.endpointResults || result.endpointResults.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const successful = result.endpointResults.filter(e => e.success).length
|
||||
return Math.round((successful / result.endpointResults.length) * 100)
|
||||
}
|
||||
|
||||
// Flow diagram computed properties
|
||||
const flowSteps = computed(() => {
|
||||
if (!latestResult.value || !latestResult.value.endpointResults) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results = latestResult.value.endpointResults
|
||||
|
||||
return results.map((result, index) => {
|
||||
const endpoint = suite.value?.endpoints?.[index]
|
||||
const nextResult = results[index + 1]
|
||||
|
||||
// Determine if this is an always-run endpoint by checking execution pattern
|
||||
// If a previous step failed but this one still executed, it must be always-run
|
||||
let isAlwaysRun = false
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (!results[i].success) {
|
||||
// A previous step failed, but we're still executing, so this must be always-run
|
||||
isAlwaysRun = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: endpoint?.name || result.name || `Step ${index + 1}`,
|
||||
endpoint: endpoint,
|
||||
result: result,
|
||||
status: determineStepStatus(result, endpoint),
|
||||
duration: result.duration || 0,
|
||||
isAlwaysRun: isAlwaysRun,
|
||||
errors: result.errors || [],
|
||||
nextStepStatus: nextResult ? determineStepStatus(nextResult, suite.value?.endpoints?.[index + 1]) : null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const completedStepsCount = computed(() => {
|
||||
return flowSteps.value.filter(step => step.status === 'success').length
|
||||
})
|
||||
|
||||
const executionProgress = computed(() => {
|
||||
if (!flowSteps.value.length) return 0
|
||||
return Math.round((completedStepsCount.value / flowSteps.value.length) * 100)
|
||||
})
|
||||
|
||||
|
||||
|
||||
// Helper functions
|
||||
const determineStepStatus = (result) => {
|
||||
if (!result) return 'not-started'
|
||||
|
||||
// Check if step was skipped
|
||||
if (result.conditionResults && result.conditionResults.some(c => c.condition.includes('SKIP'))) {
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
// Check if step failed but is always-run (still shows as failed but executed)
|
||||
if (!result.success) {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
return 'success'
|
||||
}
|
||||
|
||||
|
||||
// Event handlers
|
||||
const onStepSelected = (step, index) => {
|
||||
selectedStep.value = step
|
||||
selectedStepIndex.value = index
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.suite-details-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user