Spaces:
Sleeping
Sleeping
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>压力锻造工坊 | Load Forge Pro</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | |
| <script> | |
| tailwind.config = { | |
| theme: { | |
| extend: { | |
| colors: { | |
| dark: { | |
| 900: '#0f172a', | |
| 800: '#1e293b', | |
| 700: '#334155', | |
| }, | |
| accent: { | |
| 500: '#f97316', // Orange | |
| 600: '#ea580c', | |
| } | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <style> | |
| body { background-color: #0f172a; color: #e2e8f0; } | |
| .glass { | |
| background: rgba(30, 41, 59, 0.7); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| input, select, textarea { | |
| background-color: #0f172a; | |
| border: 1px solid #334155; | |
| color: white; | |
| } | |
| input:focus, select:focus, textarea:focus { | |
| outline: none; | |
| border-color: #f97316; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app" class="min-h-screen flex flex-col"> | |
| <!-- Header --> | |
| <header class="glass p-4 sticky top-0 z-50"> | |
| <div class="container mx-auto flex justify-between items-center"> | |
| <div class="flex items-center gap-3"> | |
| <i class="fa-solid fa-hammer text-accent-500 text-2xl"></i> | |
| <h1 class="text-xl font-bold tracking-wider">LOAD FORGE <span class="text-accent-500">PRO</span></h1> | |
| </div> | |
| <div class="text-sm text-gray-400"> | |
| <span class="mr-4"><i class="fa-solid fa-circle text-green-500 text-xs mr-1"></i> 系统就绪</span> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="flex-1 container mx-auto p-4 gap-6 grid grid-cols-1 lg:grid-cols-12"> | |
| <!-- Config Panel --> | |
| <div class="lg:col-span-4 space-y-6"> | |
| <div class="glass rounded-xl p-6"> | |
| <div class="flex justify-between items-center mb-4 border-b border-gray-700 pb-2"> | |
| <h2 class="text-lg font-semibold"><i class="fa-solid fa-sliders mr-2"></i> 压测配置</h2> | |
| <div class="flex gap-2"> | |
| <input type="file" ref="fileInput" @change="handleImport" class="hidden" accept=".json"> | |
| <button @click="$refs.fileInput.click()" class="text-xs bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-gray-300" title="导入配置"> | |
| <i class="fa-solid fa-upload"></i> | |
| </button> | |
| <button @click="handleExport" class="text-xs bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded text-gray-300" title="导出配置"> | |
| <i class="fa-solid fa-download"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="space-y-4"> | |
| <!-- URL & Method --> | |
| <div class="flex gap-2"> | |
| <select v-model="config.method" class="w-1/4 rounded px-3 py-2 font-bold"> | |
| <option value="GET">GET</option> | |
| <option value="POST">POST</option> | |
| <option value="PUT">PUT</option> | |
| <option value="DELETE">DELETE</option> | |
| </select> | |
| <input v-model="config.url" type="text" placeholder="https://api.example.com/v1/test" class="w-3/4 rounded px-3 py-2"> | |
| </div> | |
| <!-- Load Settings --> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label class="text-xs text-gray-400 block mb-1">并发数 (Users)</label> | |
| <input v-model.number="config.concurrency" type="number" min="1" max="50" class="w-full rounded px-3 py-2"> | |
| </div> | |
| <div> | |
| <label class="text-xs text-gray-400 block mb-1">持续时间 (Sec)</label> | |
| <input v-model.number="config.duration" type="number" min="5" max="300" class="w-full rounded px-3 py-2"> | |
| </div> | |
| </div> | |
| <!-- Headers --> | |
| <div> | |
| <label class="text-xs text-gray-400 block mb-1">Headers (JSON)</label> | |
| <textarea v-model="config.headersStr" rows="3" class="w-full rounded px-3 py-2 font-mono text-xs" placeholder='{"Authorization": "Bearer token"}'></textarea> | |
| </div> | |
| <!-- Body --> | |
| <div v-if="['POST', 'PUT', 'PATCH'].includes(config.method)"> | |
| <label class="text-xs text-gray-400 block mb-1">Body (JSON)</label> | |
| <textarea v-model="config.bodyStr" rows="5" class="w-full rounded px-3 py-2 font-mono text-xs" placeholder='{"key": "value"}'></textarea> | |
| </div> | |
| <!-- Actions --> | |
| <div class="pt-4 flex gap-3"> | |
| <button @click="startTest" :disabled="isRunning" | |
| class="flex-1 bg-accent-600 hover:bg-accent-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-3 rounded transition-all shadow-lg shadow-orange-900/50"> | |
| <i class="fa-solid fa-play mr-2"></i> 开始压测 | |
| </button> | |
| <button @click="stopTest" :disabled="!isRunning" | |
| class="w-1/3 bg-gray-700 hover:bg-red-600 disabled:opacity-50 text-white font-bold py-3 rounded transition-all"> | |
| <i class="fa-solid fa-stop"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Recent Logs/Errors --> | |
| <div class="glass rounded-xl p-6 h-64 overflow-y-auto"> | |
| <h2 class="text-sm font-semibold mb-2 text-gray-400">实时日志 / 错误</h2> | |
| <div v-if="stats.recent_errors.length === 0" class="text-gray-600 text-xs italic">暂无错误...</div> | |
| <ul class="space-y-2"> | |
| <li v-for="(err, idx) in stats.recent_errors" :key="idx" class="text-xs text-red-400 font-mono border-l-2 border-red-500 pl-2"> | |
| {{ err }} | |
| </li> | |
| </ul> | |
| </div> | |
| </div> | |
| <!-- Dashboard Panel --> | |
| <div class="lg:col-span-8 space-y-6"> | |
| <!-- KPI Cards --> | |
| <div class="grid grid-cols-2 md:grid-cols-4 gap-4"> | |
| <div class="glass p-4 rounded-xl text-center"> | |
| <div class="text-gray-400 text-xs uppercase">RPS (req/s)</div> | |
| <div class="text-2xl font-bold text-accent-500">{{ stats.rps || 0 }}</div> | |
| </div> | |
| <div class="glass p-4 rounded-xl text-center"> | |
| <div class="text-gray-400 text-xs uppercase">总请求</div> | |
| <div class="text-2xl font-bold text-white">{{ stats.total_requests || 0 }}</div> | |
| </div> | |
| <div class="glass p-4 rounded-xl text-center"> | |
| <div class="text-gray-400 text-xs uppercase">P95 延迟 (ms)</div> | |
| <div class="text-2xl font-bold text-yellow-400">{{ stats.p95_latency || 0 }}</div> | |
| </div> | |
| <div class="glass p-4 rounded-xl text-center"> | |
| <div class="text-gray-400 text-xs uppercase">错误率</div> | |
| <div class="text-2xl font-bold" :class="errorRate > 0 ? 'text-red-500' : 'text-green-500'"> | |
| {{ errorRate }}% | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Charts --> | |
| <div class="glass p-4 rounded-xl h-80 relative"> | |
| <h3 class="absolute top-4 left-4 text-xs font-bold text-gray-400">RPS & Latency Trend</h3> | |
| <div id="trendChart" class="w-full h-full"></div> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div class="glass p-4 rounded-xl h-64 relative"> | |
| <h3 class="absolute top-4 left-4 text-xs font-bold text-gray-400">Status Codes</h3> | |
| <div id="pieChart" class="w-full h-full"></div> | |
| </div> | |
| <div class="glass p-4 rounded-xl h-64 flex flex-col justify-center items-center"> | |
| <div class="text-center space-y-2"> | |
| <div class="text-4xl font-bold text-white">{{ stats.duration || 0 }}s</div> | |
| <div class="text-sm text-gray-400">已运行时间</div> | |
| <div class="w-full bg-gray-700 h-2 rounded-full mt-2 overflow-hidden relative" v-if="isRunning"> | |
| <div class="bg-accent-500 h-full absolute top-0 left-0 transition-all duration-1000" | |
| :style="{ width: (stats.duration / config.duration * 100) + '%' }"></div> | |
| </div> | |
| <button @click="downloadReport" class="mt-4 bg-gray-600 hover:bg-gray-500 text-white text-xs py-2 px-4 rounded shadow"> | |
| <i class="fa-solid fa-download mr-1"></i> 导出测试报告 (JSON) | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| const { createApp, ref, onMounted, computed, watch } = Vue; | |
| createApp({ | |
| setup() { | |
| const config = ref({ | |
| url: 'https://httpbin.org/get', | |
| method: 'GET', | |
| concurrency: 5, | |
| duration: 30, | |
| headersStr: '{}', | |
| bodyStr: '{}' | |
| }); | |
| const stats = ref({ | |
| running: false, | |
| duration: 0, | |
| total_requests: 0, | |
| success_count: 0, | |
| fail_count: 0, | |
| rps: 0, | |
| avg_latency: 0, | |
| p95_latency: 0, | |
| status_codes: {}, | |
| recent_errors: [] | |
| }); | |
| const isRunning = ref(false); | |
| let pollTimer = null; | |
| let trendChart = null; | |
| let pieChart = null; | |
| // Chart Data Arrays | |
| const timeData = []; | |
| const rpsData = []; | |
| const latencyData = []; | |
| const errorRate = computed(() => { | |
| if (stats.value.total_requests === 0) return 0; | |
| return ((stats.value.fail_count / stats.value.total_requests) * 100).toFixed(1); | |
| }); | |
| const initCharts = () => { | |
| trendChart = echarts.init(document.getElementById('trendChart')); | |
| pieChart = echarts.init(document.getElementById('pieChart')); | |
| const trendOption = { | |
| backgroundColor: 'transparent', | |
| tooltip: { trigger: 'axis' }, | |
| legend: { data: ['RPS', 'Avg Latency (ms)'], textStyle: { color: '#94a3b8' }, bottom: 0 }, | |
| grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true }, | |
| xAxis: { type: 'category', boundaryGap: false, data: [], axisLabel: { color: '#94a3b8' } }, | |
| yAxis: [ | |
| { type: 'value', name: 'RPS', axisLabel: { color: '#94a3b8' }, splitLine: { lineStyle: { color: '#334155' } } }, | |
| { type: 'value', name: 'ms', axisLabel: { color: '#94a3b8' }, splitLine: { show: false } } | |
| ], | |
| series: [ | |
| { name: 'RPS', type: 'line', smooth: true, data: [], itemStyle: { color: '#f97316' }, areaStyle: { color: 'rgba(249, 115, 22, 0.1)' } }, | |
| { name: 'Avg Latency (ms)', type: 'line', smooth: true, yAxisIndex: 1, data: [], itemStyle: { color: '#3b82f6' } } | |
| ] | |
| }; | |
| trendChart.setOption(trendOption); | |
| const pieOption = { | |
| backgroundColor: 'transparent', | |
| tooltip: { trigger: 'item' }, | |
| series: [ | |
| { | |
| type: 'pie', radius: ['40%', '70%'], | |
| itemStyle: { borderRadius: 5, borderColor: '#1e293b', borderWidth: 2 }, | |
| label: { show: false }, | |
| data: [] | |
| } | |
| ] | |
| }; | |
| pieChart.setOption(pieOption); | |
| window.addEventListener('resize', () => { | |
| trendChart.resize(); | |
| pieChart.resize(); | |
| }); | |
| }; | |
| const updateCharts = () => { | |
| // Update Trend | |
| trendChart.setOption({ | |
| xAxis: { data: timeData }, | |
| series: [ | |
| { data: rpsData }, | |
| { data: latencyData } | |
| ] | |
| }); | |
| // Update Pie | |
| const pieData = Object.entries(stats.value.status_codes).map(([code, count]) => { | |
| let color = '#94a3b8'; // gray | |
| if (code.startsWith('2')) color = '#22c55e'; // green | |
| else if (code.startsWith('3')) color = '#3b82f6'; // blue | |
| else if (code.startsWith('4')) color = '#eab308'; // yellow | |
| else if (code.startsWith('5')) color = '#ef4444'; // red | |
| return { value: count, name: code, itemStyle: { color } }; | |
| }); | |
| pieChart.setOption({ series: [{ data: pieData }] }); | |
| }; | |
| const startTest = async () => { | |
| try { | |
| const headers = JSON.parse(config.value.headersStr || '{}'); | |
| const body = JSON.parse(config.value.bodyStr || '{}'); | |
| const payload = { | |
| ...config.value, | |
| headers, | |
| body | |
| }; | |
| const res = await fetch('/api/start', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (res.ok) { | |
| isRunning.value = true; | |
| // Reset chart data | |
| timeData.length = 0; | |
| rpsData.length = 0; | |
| latencyData.length = 0; | |
| pollStats(); | |
| } else { | |
| const data = await res.json(); | |
| alert('Error: ' + data.error); | |
| } | |
| } catch (e) { | |
| alert('Invalid JSON in Headers or Body'); | |
| } | |
| }; | |
| const stopTest = async () => { | |
| await fetch('/api/stop', { method: 'POST' }); | |
| isRunning.value = false; | |
| }; | |
| const downloadReport = () => { | |
| const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(stats.value, null, 2)); | |
| const downloadAnchorNode = document.createElement('a'); | |
| downloadAnchorNode.setAttribute("href", dataStr); | |
| downloadAnchorNode.setAttribute("download", "load_test_report_" + new Date().toISOString() + ".json"); | |
| document.body.appendChild(downloadAnchorNode); | |
| downloadAnchorNode.click(); | |
| downloadAnchorNode.remove(); | |
| }; | |
| const handleExport = () => { | |
| const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(config.value, null, 2)); | |
| const downloadAnchorNode = document.createElement('a'); | |
| downloadAnchorNode.setAttribute("href", dataStr); | |
| downloadAnchorNode.setAttribute("download", "config_" + new Date().toISOString() + ".json"); | |
| document.body.appendChild(downloadAnchorNode); | |
| downloadAnchorNode.click(); | |
| downloadAnchorNode.remove(); | |
| }; | |
| const handleImport = (event) => { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| // 1. Size Validation (< 5MB) | |
| if (file.size > 5 * 1024 * 1024) { | |
| alert('文件过大!请上传小于 5MB 的文件。'); | |
| event.target.value = ''; // Reset input | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const content = e.target.result; | |
| // 2. Binary / Null Byte Check | |
| if (content.includes('\0')) { | |
| alert('检测到二进制内容或非法字符,请上传有效的 JSON 文件。'); | |
| event.target.value = ''; | |
| return; | |
| } | |
| try { | |
| const importedConfig = JSON.parse(content); | |
| // Merge imported config | |
| config.value = { ...config.value, ...importedConfig }; | |
| alert('配置导入成功!'); | |
| } catch (err) { | |
| alert('无效的 JSON 文件格式。'); | |
| } | |
| event.target.value = ''; // Reset input | |
| }; | |
| reader.readAsText(file); | |
| }; | |
| const pollStats = () => { | |
| if (pollTimer) clearTimeout(pollTimer); | |
| pollTimer = setTimeout(async () => { | |
| try { | |
| const res = await fetch('/api/stats'); | |
| const data = await res.json(); | |
| stats.value = data; | |
| isRunning.value = data.running; | |
| // Push data to charts | |
| if (data.running) { | |
| const now = new Date().toLocaleTimeString(); | |
| timeData.push(now); | |
| rpsData.push(data.rps); | |
| latencyData.push(data.avg_latency); | |
| // Keep only last 30 points | |
| if (timeData.length > 30) { | |
| timeData.shift(); | |
| rpsData.shift(); | |
| latencyData.shift(); | |
| } | |
| updateCharts(); | |
| } | |
| if (data.running) { | |
| pollStats(); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| }, 1000); | |
| }; | |
| onMounted(() => { | |
| initCharts(); | |
| }); | |
| return { | |
| config, | |
| stats, | |
| isRunning, | |
| errorRate, | |
| startTest, | |
| stopTest, | |
| downloadReport, | |
| handleExport, | |
| handleImport | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |