load-forge-pro / templates /index.html
Trae Assistant
Fix Vue setup return object and clean up template file
e77d5c3
<!DOCTYPE html>
<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>