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

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