937 lines
36 KiB
Vue

<template>
<div class="bg-white dark:bg-gray-800 flex flex-col">
<!-- Enhanced Response Header -->
<div class="flex items-center justify-between p-6 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<Icon name="ic:outline-api" size="20" class="text-gray-500" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Response</h3>
<div v-if="hasResponse" class="text-sm text-gray-500 dark:text-gray-400">
{{ new Date(response.timestamp || Date.now()).toLocaleTimeString() }}
</div>
</div>
<div v-if="hasResponse" class="flex items-center gap-3 flex-wrap">
<rs-badge :variant="getStatusVariant(response.status || 500)" class="font-medium">
{{ response.status || 'Error' }} {{ response.statusText || 'Request Failed' }}
</rs-badge>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span v-if="response.time" class="flex items-center gap-1">
<Icon name="ic:outline-timer" size="14" />
{{ response.time }}ms
</span>
<span v-if="response.size" class="flex items-center gap-1">
<Icon name="ic:outline-data-usage" size="14" />
{{ formatBytes(response.size) }}
</span>
</div>
</div>
</div>
<!-- Response Content Area -->
<div v-if="hasResponse" class="p-6">
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800">
<!-- Enhanced Response Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<nav class="px-4 flex space-x-6 overflow-x-auto">
<button
v-for="tab in responseTabs"
:key="tab.id"
@click="responseActiveTab = tab.id"
:class="[
responseActiveTab === tab.id
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm flex items-center gap-2 flex-shrink-0'
]"
>
<Icon :name="tab.icon" size="16" />
{{ tab.label }}
<rs-badge v-if="tab.count" variant="secondary" size="sm">{{ tab.count }}</rs-badge>
</button>
</nav>
</div>
<!-- Response Content -->
<div>
<!-- Body Tab -->
<div v-if="responseActiveTab === 'body'">
<!-- Enhanced Response Tools -->
<div v-if="isJsonResponse || response.data" class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 flex-wrap">
<rs-button
v-if="isJsonResponse"
variant="secondary-outline"
size="sm"
@click="beautifyResponseJson"
class="flex items-center gap-1"
>
<Icon name="ic:outline-auto-fix-high" size="16" />
Beautify
</rs-button>
<rs-button
v-if="isJsonResponse"
variant="secondary-outline"
size="sm"
@click="minifyResponseJson"
class="flex items-center gap-1"
>
<Icon name="ic:outline-compress" size="16" />
Minify
</rs-button>
<rs-button
variant="secondary-outline"
size="sm"
@click="copyResponseToClipboard"
class="flex items-center gap-1"
>
<Icon name="ic:outline-content-copy" size="16" />
Copy
</rs-button>
<rs-button
variant="secondary-outline"
size="sm"
@click="downloadResponse"
class="flex items-center gap-1"
>
<Icon name="ic:outline-download" size="16" />
Download
</rs-button>
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
<!-- New Search Feature -->
<div class="relative">
<input
v-model="searchQuery"
type="text"
placeholder="Search in response..."
class="w-64 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<div v-if="searchQuery" class="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
<span v-if="searchMatches.length > 0" class="text-xs text-gray-500 dark:text-gray-400">
{{ currentSearchIndex + 1 }}/{{ searchMatches.length }}
</span>
<button
@click="navigateSearch(-1)"
:disabled="searchMatches.length === 0"
class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
<Icon name="ic:outline-keyboard-arrow-up" size="14" />
</button>
<button
@click="navigateSearch(1)"
:disabled="searchMatches.length === 0"
class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
<Icon name="ic:outline-keyboard-arrow-down" size="14" />
</button>
<button
@click="clearSearch"
class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
>
<Icon name="ic:outline-close" size="14" />
</button>
</div>
</div>
</div>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">Format:</span>
<rs-badge variant="info" size="sm">{{ responseFormat }}</rs-badge>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">Size:</span>
<rs-badge variant="secondary" size="sm">{{ formattedResponseData.length }} chars</rs-badge>
</div>
<div v-if="searchQuery && searchMatches.length > 0" class="flex items-center gap-2">
<span class="text-xs text-gray-500">Matches:</span>
<rs-badge variant="primary" size="sm">{{ searchMatches.length }}</rs-badge>
</div>
</div>
</div>
<!-- Response Body Content -->
<div class="p-6">
<pre
ref="responseBody"
class="text-sm bg-gray-50 dark:bg-gray-900 p-4 rounded-lg whitespace-pre-wrap w-full break-words border font-mono leading-relaxed"
:class="{
'language-json': isJsonResponse,
'language-xml': isXmlResponse,
'language-html': isHtmlResponse
}"
v-html="highlightedResponseData"
></pre>
</div>
</div>
<!-- Headers Tab -->
<div v-if="responseActiveTab === 'headers'" class="p-6 space-y-4">
<!-- Copy Headers Button -->
<div class="flex justify-between items-center">
<h4 class="font-medium text-gray-900 dark:text-white">Response Headers</h4>
<div class="flex items-center gap-2">
<!-- Header Search -->
<input
v-model="headerSearchQuery"
type="text"
placeholder="Filter headers..."
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<rs-button variant="secondary-outline" size="sm" @click="copyHeadersToClipboard">
<Icon name="ic:outline-content-copy" size="16" class="mr-1" />
Copy All
</rs-button>
</div>
</div>
<!-- Headers List -->
<div class="space-y-2">
<div v-for="(value, key) in filteredHeaders" :key="key" class="flex flex-wrap gap-2 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<span class="font-medium text-gray-700 dark:text-gray-300 min-w-48 break-words">{{ key }}:</span>
<span class="text-gray-600 dark:text-gray-400 break-words flex-1 font-mono text-sm">{{ value }}</span>
<rs-button
variant="text"
size="sm"
@click="copyHeaderToClipboard(key, value)"
class="opacity-60 hover:opacity-100 flex-shrink-0"
>
<Icon name="ic:outline-content-copy" size="14" />
</rs-button>
</div>
<div v-if="Object.keys(filteredHeaders).length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<Icon name="ic:outline-web-asset-off" size="48" class="mx-auto mb-4 opacity-50" />
<p v-if="headerSearchQuery">No headers match your search</p>
<p v-else>No response headers available</p>
</div>
</div>
</div>
<!-- Cookies Tab -->
<div v-if="responseActiveTab === 'cookies'" class="p-6 space-y-4">
<div class="flex justify-between items-center">
<h4 class="font-medium text-gray-900 dark:text-white">Cookies</h4>
<div class="flex items-center gap-2">
<!-- Cookie Search -->
<input
v-model="cookieSearchQuery"
type="text"
placeholder="Filter cookies..."
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<rs-button variant="secondary-outline" size="sm" @click="clearCookies">
<Icon name="ic:outline-clear" size="16" class="mr-1" />
Clear All
</rs-button>
</div>
</div>
<div class="space-y-2">
<div v-for="cookie in filteredCookies" :key="cookie.name" class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-gray-900 dark:text-white">{{ cookie.name }}</span>
<rs-button variant="text" size="sm" @click="copyCookie(cookie)">
<Icon name="ic:outline-content-copy" size="14" />
</rs-button>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div><span class="font-medium">Value:</span> {{ cookie.value }}</div>
<div v-if="cookie.domain"><span class="font-medium">Domain:</span> {{ cookie.domain }}</div>
<div v-if="cookie.path"><span class="font-medium">Path:</span> {{ cookie.path }}</div>
<div v-if="cookie.expires"><span class="font-medium">Expires:</span> {{ cookie.expires }}</div>
<div v-if="cookie.httpOnly" class="flex items-center gap-1">
<Icon name="ic:outline-security" size="14" />
<span>HTTP Only</span>
</div>
<div v-if="cookie.secure" class="flex items-center gap-1">
<Icon name="ic:outline-lock" size="14" />
<span>Secure</span>
</div>
</div>
</div>
<div v-if="filteredCookies.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<Icon name="ic:outline-cookie" size="48" class="mx-auto mb-4 opacity-50" />
<p v-if="cookieSearchQuery">No cookies match your search</p>
<p v-else>No cookies in response</p>
</div>
</div>
</div>
<!-- Timeline Tab -->
<div v-if="responseActiveTab === 'timeline'" class="p-6 space-y-6">
<div class="flex items-center justify-between">
<h4 class="font-medium text-gray-900 dark:text-white">Request Timeline</h4>
<div class="flex items-center gap-2">
<rs-button variant="secondary-outline" size="sm" @click="exportTimeline">
<Icon name="ic:outline-download" size="16" class="mr-1" />
Export
</rs-button>
<rs-button variant="secondary-outline" size="sm" @click="compareWithPrevious" :disabled="!previousResponse">
<Icon name="ic:outline-compare" size="16" class="mr-1" />
Compare
</rs-button>
</div>
</div>
<!-- Performance Metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<div class="text-sm text-gray-600 dark:text-gray-400">Response Time</div>
<div class="text-xl font-bold text-gray-900 dark:text-white">{{ response.time || 0 }}ms</div>
<div class="text-xs" :class="getPerformanceColor(response.time || 0)">
{{ getPerformanceLabel(response.time || 0) }}
</div>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<div class="text-sm text-gray-600 dark:text-gray-400">Response Size</div>
<div class="text-xl font-bold text-gray-900 dark:text-white">{{ formatBytes(response.size || 0) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ response.size || 0 }} bytes</div>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<div class="text-sm text-gray-600 dark:text-gray-400">Status Code</div>
<div class="text-xl font-bold" :class="getStatusColor(response.status || 500)">{{ response.status || 'N/A' }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ response.statusText || 'Unknown' }}</div>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<div class="text-sm text-gray-600 dark:text-gray-400">Headers Count</div>
<div class="text-xl font-bold text-gray-900 dark:text-white">{{ Object.keys(response.headers || {}).length }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">response headers</div>
</div>
</div>
<!-- Timeline Waterfall -->
<div class="space-y-3">
<h5 class="font-medium text-gray-900 dark:text-white">Performance Waterfall</h5>
<div class="space-y-2">
<div v-for="(stage, index) in timelineStages" :key="stage.name" class="flex items-center gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex-shrink-0">
<div class="w-3 h-3 rounded-full" :class="stage.color"></div>
</div>
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-white">{{ stage.name }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">{{ stage.description }}</div>
</div>
<div class="text-sm font-medium text-gray-900 dark:text-white min-w-16 text-right">
{{ stage.time }}ms
</div>
<div class="w-32 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-500"
:class="stage.color"
:style="{ width: `${(stage.time / (response.time || 1)) * 100}%` }"
></div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 min-w-10 text-right">
{{ Math.round((stage.time / (response.time || 1)) * 100) }}%
</div>
</div>
</div>
</div>
<!-- Performance Analysis -->
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h5 class="font-medium text-blue-900 dark:text-blue-100 mb-2">Performance Analysis</h5>
<div class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<div v-for="insight in performanceInsights" :key="insight.metric" class="flex items-center gap-2">
<Icon :name="insight.icon" size="14" />
<span>{{ insight.message }}</span>
</div>
</div>
</div>
<!-- Total Time Summary -->
<div class="p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
<div class="flex items-center justify-between">
<span class="font-medium text-primary-900 dark:text-primary-100">Total Request Time</span>
<span class="text-xl font-bold text-primary-900 dark:text-primary-100">{{ response.time || 0 }}ms</span>
</div>
<div class="text-sm text-primary-700 dark:text-primary-300 mt-1">
{{ getOverallPerformanceMessage() }}
</div>
</div>
</div>
<!-- Test Results Tab -->
<div v-if="responseActiveTab === 'tests'" class="p-6 space-y-4">
<div class="flex justify-between items-center">
<h4 class="font-medium text-gray-900 dark:text-white">Test Results</h4>
<div class="flex items-center gap-2">
<rs-button variant="secondary-outline" size="sm" @click="showTestScriptsModal = true">
<Icon name="ic:outline-edit" size="16" class="mr-1" />
Scripts
</rs-button>
<rs-button variant="primary" size="sm" @click="runBasicTests">
<Icon name="ic:outline-play-arrow" size="16" class="mr-1" />
Run Tests
</rs-button>
</div>
</div>
<div class="space-y-2">
<div v-for="test in testResults" :key="test.name" class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<Icon
:name="test.passed ? 'ic:outline-check-circle' : 'ic:outline-error'"
size="20"
:class="test.passed ? 'text-green-500' : 'text-red-500'"
/>
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-white">{{ test.name }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">{{ test.description }}</div>
</div>
<rs-badge :variant="test.passed ? 'success' : 'danger'" size="sm">
{{ test.passed ? 'PASS' : 'FAIL' }}
</rs-badge>
</div>
<div v-if="testResults.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
<Icon name="ic:outline-quiz" size="48" class="mx-auto mb-4 opacity-50" />
<p>No test results yet</p>
<p class="text-xs mt-2">Click "Run Tests" to execute basic validation tests</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400 p-6">
<div class="text-center">
<Icon name="ic:outline-send" size="64" class="mx-auto mb-6 opacity-50" />
<h3 class="text-lg font-medium mb-2">Ready to send your first request?</h3>
<p class="text-sm">Configure your request above and click Send to see the response here</p>
</div>
</div>
<!-- Test Scripts Modal -->
<TestScriptsModal
v-if="showTestScriptsModal"
@close="showTestScriptsModal = false"
/>
</div>
</template>
<script setup>
import TestScriptsModal from './TestScriptsModal.vue'
const { formatJson, getStatusVariant } = useApiRequest()
const { response, responseActiveTab, showNotification } = useApiPlatform()
// Enhanced tab configuration
const responseTabs = computed(() => [
{
id: 'body',
label: 'Body',
icon: 'ic:outline-description',
count: null
},
{
id: 'headers',
label: 'Headers',
icon: 'ic:outline-list',
count: response.value?.headers ? Object.keys(response.value.headers).length : 0
},
{
id: 'cookies',
label: 'Cookies',
icon: 'ic:outline-cookie',
count: parsedCookies.value.length
},
{
id: 'timeline',
label: 'Timeline',
icon: 'ic:outline-timeline',
count: null
},
{
id: 'tests',
label: 'Tests',
icon: 'ic:outline-quiz',
count: testResults.value.length
}
])
// Check if we have a response to show
const hasResponse = computed(() => {
return response.value && (response.value.status || response.value.data)
})
// Response format detection
const isJsonResponse = computed(() => {
if (!response.value?.data) return false
return typeof response.value.data === 'object' ||
(typeof response.value.data === 'string' && isValidJson(response.value.data))
})
const isXmlResponse = computed(() => {
if (!response.value?.data || typeof response.value.data !== 'string') return false
return response.value.data.trim().startsWith('<?xml') || response.value.data.trim().startsWith('<')
})
const isHtmlResponse = computed(() => {
if (!response.value?.data || typeof response.value.data !== 'string') return false
return response.value.data.trim().toLowerCase().includes('<html')
})
const responseFormat = computed(() => {
if (isJsonResponse.value) return 'JSON'
if (isXmlResponse.value) return 'XML'
if (isHtmlResponse.value) return 'HTML'
return 'TEXT'
})
// Cookie parsing
const parsedCookies = computed(() => {
if (!response.value?.headers) return []
const cookieHeaders = []
Object.entries(response.value.headers).forEach(([key, value]) => {
if (key.toLowerCase() === 'set-cookie') {
if (Array.isArray(value)) {
cookieHeaders.push(...value)
} else {
cookieHeaders.push(value)
}
}
})
return cookieHeaders.map(cookieString => {
const parts = cookieString.split(';').map(part => part.trim())
const [name, value] = parts[0].split('=')
const cookie = { name, value: value || '' }
parts.slice(1).forEach(part => {
const [key, val] = part.split('=')
const lowerKey = key.toLowerCase()
if (lowerKey === 'domain') cookie.domain = val
else if (lowerKey === 'path') cookie.path = val
else if (lowerKey === 'expires') cookie.expires = val
else if (lowerKey === 'httponly') cookie.httpOnly = true
else if (lowerKey === 'secure') cookie.secure = true
})
return cookie
})
})
// Filtered headers and cookies
const filteredHeaders = computed(() => {
if (!response.value?.headers) return {}
if (!headerSearchQuery.value) return response.value.headers
const query = headerSearchQuery.value.toLowerCase()
const filtered = {}
Object.entries(response.value.headers).forEach(([key, value]) => {
if (key.toLowerCase().includes(query) || value.toLowerCase().includes(query)) {
filtered[key] = value
}
})
return filtered
})
const filteredCookies = computed(() => {
if (!cookieSearchQuery.value) return parsedCookies.value
const query = cookieSearchQuery.value.toLowerCase()
return parsedCookies.value.filter(cookie =>
cookie.name.toLowerCase().includes(query) ||
cookie.value.toLowerCase().includes(query) ||
(cookie.domain && cookie.domain.toLowerCase().includes(query))
)
})
// Timeline stages
const timelineStages = computed(() => {
const total = response.value?.time || 0
const stages = [
{ name: 'DNS Lookup', description: 'Resolving domain name', time: Math.round(total * 0.1), color: 'bg-blue-500' },
{ name: 'TCP Connect', description: 'Establishing connection', time: Math.round(total * 0.15), color: 'bg-green-500' },
{ name: 'TLS Handshake', description: 'SSL/TLS negotiation', time: Math.round(total * 0.2), color: 'bg-yellow-500' },
{ name: 'Request Sent', description: 'Sending request data', time: Math.round(total * 0.05), color: 'bg-purple-500' },
{ name: 'Server Processing', description: 'Server processing time', time: Math.round(total * 0.4), color: 'bg-red-500' },
{ name: 'Response Received', description: 'Downloading response', time: Math.round(total * 0.1), color: 'bg-indigo-500' }
]
return stages
})
// Performance insights
const performanceInsights = computed(() => {
const insights = []
const totalTime = response.value?.time || 0
if (totalTime > 1000) {
insights.push({
metric: 'slow',
icon: 'ic:outline-warning',
message: 'Response time is over 1 second. Consider optimization.'
})
} else if (totalTime < 100) {
insights.push({
metric: 'fast',
icon: 'ic:outline-speed',
message: 'Excellent response time! Under 100ms.'
})
}
const size = response.value?.size || 0
if (size > 1024 * 1024) { // 1MB
insights.push({
metric: 'large',
icon: 'ic:outline-info',
message: 'Large response size. Consider pagination or compression.'
})
}
const status = response.value?.status
if (status && status >= 400) {
insights.push({
metric: 'error',
icon: 'ic:outline-error',
message: 'HTTP error status. Check your request configuration.'
})
}
return insights
})
// Response highlighting with search
const highlightedResponseData = computed(() => {
if (!searchQuery.value || !formattedResponseData.value) {
return escapeHtml(formattedResponseData.value)
}
const query = searchQuery.value
const regex = new RegExp(`(${escapeRegex(query)})`, 'gi')
const matches = formattedResponseData.value.match(regex) || []
searchMatches.value = matches
return escapeHtml(formattedResponseData.value).replace(
regex,
`<mark class="bg-yellow-200 dark:bg-yellow-800 text-gray-900 dark:text-white">$1</mark>`
)
})
// Test results
const testResults = ref([])
const showTestScriptsModal = ref(false)
// Response body formatting
const formattedResponseData = ref('')
// Search functionality
const searchQuery = ref('')
const headerSearchQuery = ref('')
const cookieSearchQuery = ref('')
const searchMatches = ref([])
const currentSearchIndex = ref(0)
// Performance tracking
const previousResponse = ref(null)
const formatResponseData = () => {
if (!response.value?.data) {
formattedResponseData.value = 'No response data'
return
}
if (typeof response.value.data === 'object') {
formattedResponseData.value = JSON.stringify(response.value.data, null, 2)
} else if (typeof response.value.data === 'string' && isValidJson(response.value.data)) {
try {
const parsed = JSON.parse(response.value.data)
formattedResponseData.value = JSON.stringify(parsed, null, 2)
} catch (e) {
formattedResponseData.value = response.value.data
}
} else {
formattedResponseData.value = String(response.value.data)
}
}
// Utility functions
const isValidJson = (str) => {
try {
JSON.parse(str)
return true
} catch (e) {
return false
}
}
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// Response JSON tools
const beautifyResponseJson = () => {
if (!isJsonResponse.value) return
try {
let data = response.value.data
if (typeof data === 'string') {
data = JSON.parse(data)
}
formattedResponseData.value = JSON.stringify(data, null, 2)
showNotification('JSON beautified', 'success', 2000)
} catch (error) {
showNotification('Failed to beautify JSON', 'error')
}
}
const minifyResponseJson = () => {
if (!isJsonResponse.value) return
try {
let data = response.value.data
if (typeof data === 'string') {
data = JSON.parse(data)
}
formattedResponseData.value = JSON.stringify(data)
showNotification('JSON minified', 'success', 2000)
} catch (error) {
showNotification('Failed to minify JSON', 'error')
}
}
const copyResponseToClipboard = async () => {
try {
await navigator.clipboard.writeText(formattedResponseData.value)
showNotification('Response copied to clipboard', 'success', 2000)
} catch (error) {
showNotification('Failed to copy response', 'error')
}
}
const downloadResponse = () => {
try {
const blob = new Blob([formattedResponseData.value], {
type: isJsonResponse.value ? 'application/json' : 'text/plain'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const extension = isJsonResponse.value ? 'json' : 'txt'
a.href = url
a.download = `response-${Date.now()}.${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification('Response downloaded', 'success', 2000)
} catch (error) {
showNotification('Failed to download response', 'error')
}
}
// Headers tools
const copyHeadersToClipboard = async () => {
try {
if (!response.value || !response.value.headers) {
showNotification('No headers to copy', 'warning')
return
}
const headersText = Object.entries(response.value.headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
await navigator.clipboard.writeText(headersText)
showNotification('Headers copied to clipboard', 'success', 2000)
} catch (error) {
showNotification('Failed to copy headers', 'error')
}
}
const copyHeaderToClipboard = async (key, value) => {
try {
await navigator.clipboard.writeText(`${key}: ${value}`)
showNotification(`Header "${key}" copied`, 'success', 2000)
} catch (error) {
showNotification('Failed to copy header', 'error')
}
}
// Cookie tools
const clearCookies = () => {
showNotification('Cookies cleared from display', 'info', 2000)
}
const copyCookie = async (cookie) => {
try {
await navigator.clipboard.writeText(`${cookie.name}=${cookie.value}`)
showNotification(`Cookie "${cookie.name}" copied`, 'success', 2000)
} catch (error) {
showNotification('Failed to copy cookie', 'error')
}
}
// Utility functions
const escapeHtml = (text) => {
if (!text) return ''
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
const escapeRegex = (string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const getPerformanceColor = (time) => {
if (time < 100) return 'text-green-600 dark:text-green-400'
if (time < 500) return 'text-yellow-600 dark:text-yellow-400'
return 'text-red-600 dark:text-red-400'
}
const getPerformanceLabel = (time) => {
if (time < 100) return 'Excellent'
if (time < 500) return 'Good'
if (time < 1000) return 'Average'
return 'Slow'
}
const getStatusColor = (status) => {
if (status >= 200 && status < 300) return 'text-green-600 dark:text-green-400'
if (status >= 300 && status < 400) return 'text-blue-600 dark:text-blue-400'
if (status >= 400 && status < 500) return 'text-yellow-600 dark:text-yellow-400'
return 'text-red-600 dark:text-red-400'
}
const getOverallPerformanceMessage = () => {
const time = response.value?.time || 0
if (time < 100) return 'Outstanding performance! Your API is responding very quickly.'
if (time < 500) return 'Good performance. Response time is within acceptable range.'
if (time < 1000) return 'Average performance. Consider optimizing for better user experience.'
return 'Slow performance detected. Optimization recommended.'
}
// Search functions
const navigateSearch = (direction) => {
if (searchMatches.value.length === 0) return
currentSearchIndex.value += direction
if (currentSearchIndex.value >= searchMatches.value.length) {
currentSearchIndex.value = 0
} else if (currentSearchIndex.value < 0) {
currentSearchIndex.value = searchMatches.value.length - 1
}
}
const clearSearch = () => {
searchQuery.value = ''
searchMatches.value = []
currentSearchIndex.value = 0
}
// Performance functions
const exportTimeline = () => {
const timelineData = {
timestamp: new Date().toISOString(),
url: response.value?.url || 'Unknown',
totalTime: response.value?.time || 0,
status: response.value?.status || 0,
size: response.value?.size || 0,
stages: timelineStages.value,
insights: performanceInsights.value
}
const blob = new Blob([JSON.stringify(timelineData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `timeline-${Date.now()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification('Timeline exported successfully', 'success', 2000)
}
const compareWithPrevious = () => {
if (!previousResponse.value) {
showNotification('No previous response to compare with', 'warning')
return
}
const current = response.value?.time || 0
const previous = previousResponse.value?.time || 0
const diff = current - previous
const percentChange = previous > 0 ? ((diff / previous) * 100).toFixed(1) : 0
let message = `Performance comparison: `
if (diff > 0) {
message += `${diff}ms slower (${percentChange}% increase)`
} else if (diff < 0) {
message += `${Math.abs(diff)}ms faster (${Math.abs(percentChange)}% improvement)`
} else {
message += 'Same performance as previous request'
}
showNotification(message, diff > 50 ? 'warning' : 'info', 4000)
}
// Test functions
const runBasicTests = () => {
testResults.value = []
if (!response.value) return
// Status code tests
if (response.value.status) {
testResults.value.push({
name: 'Status Code is 2xx',
description: 'Response has successful status code',
passed: response.value.status >= 200 && response.value.status < 300
})
testResults.value.push({
name: 'Response Time < 1000ms',
description: 'Response was received quickly',
passed: (response.value.time || 0) < 1000
})
}
// Content tests
if (response.value.data) {
testResults.value.push({
name: 'Response Has Body',
description: 'Response contains data',
passed: !!response.value.data
})
if (isJsonResponse.value) {
testResults.value.push({
name: 'Valid JSON Response',
description: 'Response body is valid JSON',
passed: true
})
}
}
// Headers tests
if (response.value.headers) {
testResults.value.push({
name: 'Content-Type Header Present',
description: 'Response includes content type',
passed: !!response.value.headers['content-type'] || !!response.value.headers['Content-Type']
})
}
showNotification(`${testResults.value.length} tests completed`, 'info', 2000)
}
// Watch for response changes
watch(() => response.value, (newResponse, oldResponse) => {
if (oldResponse && oldResponse !== newResponse) {
previousResponse.value = oldResponse
}
formatResponseData()
}, { immediate: true, deep: true })
// Clear search when switching tabs
watch(() => responseActiveTab.value, () => {
if (responseActiveTab.value !== 'body') {
clearSearch()
}
})
</script>