937 lines
36 KiB
Vue
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> |