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:
171
web/app/src/components/SuiteCard.vue
Normal file
171
web/app/src/components/SuiteCard.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<Card class="suite h-full flex flex-col transition hover:shadow-lg hover:scale-[1.01] dark:hover:border-gray-700">
|
||||
<CardHeader class="suite-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0">
|
||||
<div class="flex items-start justify-between gap-2 sm:gap-3">
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<CardTitle class="text-base sm:text-lg truncate">
|
||||
<span
|
||||
class="hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate"
|
||||
@click="navigateToDetails"
|
||||
@keydown.enter="navigateToDetails"
|
||||
:title="suite.name"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
:aria-label="`View details for suite ${suite.name}`">
|
||||
{{ suite.name }}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
||||
<span v-if="suite.group" class="truncate" :title="suite.group">{{ suite.group }}</span>
|
||||
<span v-if="suite.group && endpointCount">•</span>
|
||||
<span v-if="endpointCount">{{ endpointCount }} endpoint{{ endpointCount !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-2">
|
||||
<StatusBadge :status="currentStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="suite-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="text-xs text-muted-foreground">Success Rate: {{ successRate }}%</p>
|
||||
<p class="text-xs text-muted-foreground" v-if="averageDuration">{{ averageDuration }}ms avg</p>
|
||||
</div>
|
||||
<div class="flex gap-0.5">
|
||||
<div
|
||||
v-for="(result, index) in displayResults"
|
||||
:key="index"
|
||||
:class="[
|
||||
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
|
||||
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
|
||||
]"
|
||||
@mouseenter="result && showTooltip(result, $event)"
|
||||
@mouseleave="hideTooltip($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>{{ newestResultTime }}</span>
|
||||
<span>{{ oldestResultTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { generatePrettyTimeAgo } from '@/utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
suite: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
maxResults: {
|
||||
type: Number,
|
||||
default: 50
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
// Computed properties
|
||||
const displayResults = computed(() => {
|
||||
const results = [...(props.suite.results || [])]
|
||||
while (results.length < props.maxResults) {
|
||||
results.unshift(null)
|
||||
}
|
||||
return results.slice(-props.maxResults)
|
||||
})
|
||||
|
||||
const currentStatus = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 'unknown'
|
||||
}
|
||||
return props.suite.results[props.suite.results.length - 1].success ? 'healthy' : 'unhealthy'
|
||||
})
|
||||
|
||||
const endpointCount = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const latestResult = props.suite.results[props.suite.results.length - 1]
|
||||
return latestResult.endpointResults ? latestResult.endpointResults.length : 0
|
||||
})
|
||||
|
||||
const successRate = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const successful = props.suite.results.filter(r => r.success).length
|
||||
return Math.round((successful / props.suite.results.length) * 100)
|
||||
})
|
||||
|
||||
const averageDuration = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const total = props.suite.results.reduce((sum, r) => sum + (r.duration || 0), 0)
|
||||
// Convert nanoseconds to milliseconds
|
||||
return Math.round((total / props.suite.results.length) / 1000000)
|
||||
})
|
||||
|
||||
const oldestResultTime = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
const oldestResult = props.suite.results[0]
|
||||
return generatePrettyTimeAgo(oldestResult.timestamp)
|
||||
})
|
||||
|
||||
const newestResultTime = computed(() => {
|
||||
if (!props.suite.results || props.suite.results.length === 0) {
|
||||
return 'Now'
|
||||
}
|
||||
|
||||
const newestResult = props.suite.results[props.suite.results.length - 1]
|
||||
return generatePrettyTimeAgo(newestResult.timestamp)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const navigateToDetails = () => {
|
||||
router.push(`/suites/${props.suite.key}`)
|
||||
}
|
||||
|
||||
const showTooltip = (result, event) => {
|
||||
emit('showTooltip', result, event)
|
||||
}
|
||||
|
||||
const hideTooltip = (event) => {
|
||||
emit('showTooltip', null, event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.suite {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.suite:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.suite-header {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark .suite-header {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user