Compare commits
14
Commits
55244e9a45
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a8c563b32 | ||
|
|
370a21760c | ||
|
|
f971de6821 | ||
|
|
0f7102a41d | ||
|
|
ec2b78533d | ||
|
|
36e0d87cec | ||
|
|
c4e278d544 | ||
|
|
5519697f87 | ||
|
|
78bb7d87ab | ||
|
|
d516dddf1d | ||
|
|
677f606935 | ||
|
|
cd70157aea | ||
|
|
ac614be190 | ||
|
|
880c199ce4 |
@@ -51,7 +51,7 @@ func buildRequest(opts Options) ([]byte, error) {
|
||||
|
||||
body := requestBody{
|
||||
User: ttsUser{UID: opts.UID},
|
||||
Namespace: "BidirectionalTTS",
|
||||
Namespace: "UnidirectionalTTS",
|
||||
ReqParams: ttsReqParams{
|
||||
Text: opts.Text,
|
||||
Speaker: opts.Speaker,
|
||||
|
||||
@@ -85,7 +85,9 @@ func ParseStream(body io.Reader, started time.Time) (*ParsedStream, error) {
|
||||
Sequence: resp.Sequence,
|
||||
})
|
||||
}
|
||||
case "sentence":
|
||||
case "sentence", "":
|
||||
// HTTP 单向协议下,音频帧的 event 字段可能是空也可能是 "sentence";
|
||||
// 两种都当音频处理。
|
||||
if resp.Data == "" {
|
||||
continue
|
||||
}
|
||||
@@ -104,12 +106,6 @@ func ParseStream(body io.Reader, started time.Time) (*ParsedStream, error) {
|
||||
out.FirstChunk = time.Since(started)
|
||||
gotFirstChunk = true
|
||||
}
|
||||
case "":
|
||||
// 无 event 字段的元数据行(上游 TTSSentenceStart 偶发省略 event),
|
||||
// debug 模式打印便于排查;正常情况静默跳过。
|
||||
if common.DebugLog && resp.Code == 0 {
|
||||
log.Printf("volcano: 无 event 字段 code=%d sequence=%d sentence=%s data_len=%d", resp.Code, resp.Sequence, resp.SentenceText(), len(resp.Data))
|
||||
}
|
||||
default:
|
||||
if common.DebugLog {
|
||||
log.Printf("volcano: 忽略未识别事件 event=%q sequence=%d sentence=%s data_len=%d", resp.Event, resp.Sequence, resp.SentenceText(), len(resp.Data))
|
||||
|
||||
+622
-177
@@ -4,220 +4,665 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TTS 服务监控</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f1a;
|
||||
--bg-card: #131826;
|
||||
--bg-elev: #1a2138;
|
||||
--border: #232b42;
|
||||
--text: #e4e7ee;
|
||||
--text-dim: #8a92a6;
|
||||
--text-mute: #5a6275;
|
||||
--accent: #00d4ff;
|
||||
--accent-2: #7b2ff7;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh; color: #e0e0e0; padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
min-height: 100vh; padding: 24px; font-size: 14px;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0,212,255,0.08), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(123,47,247,0.06), transparent);
|
||||
}
|
||||
#app { max-width: 1200px; margin: 0 auto; }
|
||||
.header { text-align: center; margin-bottom: 30px; }
|
||||
.header h1 {
|
||||
font-size: 2em;
|
||||
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
margin-bottom: 10px;
|
||||
#app { max-width: 1280px; margin: 0 auto; }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 24px; flex-wrap: wrap; gap: 16px;
|
||||
}
|
||||
.header .version { color: #888; font-size: 0.9em; }
|
||||
.actions { display: flex; justify-content: center; gap: 12px; margin-top: 15px; flex-wrap: wrap; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.logo {
|
||||
width: 44px; height: 44px; border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 18px; color: #fff; box-shadow: 0 4px 20px rgba(0,212,255,0.3);
|
||||
}
|
||||
.header h1 { font-size: 20px; font-weight: 600; }
|
||||
.header .sub { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
|
||||
.status-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.status-badge.ok { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.status-badge.error { background: rgba(239,68,68,0.15); color: var(--danger); }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.status-dot.live { animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
|
||||
border: none; color: white; padding: 10px 24px; border-radius: 8px;
|
||||
cursor: pointer; font-size: 14px; text-decoration: none;
|
||||
transition: opacity 0.3s; display: inline-block;
|
||||
background: var(--bg-elev); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px;
|
||||
transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn.secondary {
|
||||
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2);
|
||||
.btn:hover { border-color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: #00121a; border-color: var(--accent); font-weight: 500; }
|
||||
.btn-primary:hover { background: #33ddff; }
|
||||
|
||||
.stats {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px; margin-bottom: 20px;
|
||||
}
|
||||
.grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px; margin-bottom: 20px;
|
||||
.stat-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 18px; transition: all 0.2s;
|
||||
}
|
||||
.stat-card:hover { border-color: rgba(0,212,255,0.4); transform: translateY(-1px); }
|
||||
.stat-label { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; margin-top: 6px; color: var(--text); }
|
||||
.stat-value.accent { color: var(--accent); }
|
||||
.stat-value.success { color: var(--success); }
|
||||
.stat-value.warning { color: var(--warning); }
|
||||
.stat-value.danger { color: var(--danger); }
|
||||
.stat-foot { font-size: 11px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.section { margin-bottom: 20px; }
|
||||
.section-title {
|
||||
font-size: 13px; font-weight: 600; color: var(--text-dim);
|
||||
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.section-title::before {
|
||||
content: ""; width: 3px; height: 14px; background: var(--accent); border-radius: 2px;
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
background: rgba(255,255,255,0.05); border-radius: 16px; padding: 24px;
|
||||
backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1);
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 20px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 14px; color: #888; text-transform: uppercase; letter-spacing: 1px;
|
||||
margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
|
||||
.card-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-title .dot { width: 8px; height: 8px; border-radius: 50%; background: #00d4ff; }
|
||||
.card-title .dot.error { background: #ff4757; }
|
||||
.stat-value {
|
||||
font-size: 2em; font-weight: bold;
|
||||
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
.card-title { font-size: 14px; font-weight: 600; }
|
||||
.card-desc { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
|
||||
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td {
|
||||
text-align: left; padding: 8px 10px; font-size: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.stat-label { color: #888; font-size: 14px; margin-top: 5px; }
|
||||
.info-row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
.table th { color: var(--text-dim); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table td.num { font-family: "SF Mono", Monaco, "Cascadia Code", monospace; text-align: right; color: var(--accent); font-weight: 500; }
|
||||
.table td.num.success { color: var(--success); }
|
||||
.table td.num.warning { color: var(--warning); }
|
||||
.table td.num.danger { color: var(--danger); }
|
||||
.table td.label-cell { color: var(--text-dim); font-family: "SF Mono", Monaco, monospace; font-size: 11px; }
|
||||
.table td.name { color: var(--text); font-family: "SF Mono", Monaco, monospace; }
|
||||
|
||||
.tag {
|
||||
display: inline-block; padding: 2px 6px; border-radius: 4px;
|
||||
background: var(--bg-elev); color: var(--text-dim);
|
||||
font-size: 10px; font-family: "SF Mono", Monaco, monospace;
|
||||
margin-right: 4px; border: 1px solid var(--border);
|
||||
}
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { color: #888; }
|
||||
.info-value { color: #fff; font-family: 'Monaco', 'Menlo', monospace; }
|
||||
.info-value.success { color: #2ed573; }
|
||||
.info-value.error { color: #ff4757; }
|
||||
.memory-stat { display: flex; justify-content: space-around; text-align: center; }
|
||||
.memory-stat .value { font-size: 1.2em; font-weight: bold; color: #00d4ff; }
|
||||
.memory-stat .label { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.tag.status-ok { background: rgba(34,197,94,0.12); color: var(--success); border-color: rgba(34,197,94,0.3); }
|
||||
.tag.status-error { background: rgba(239,68,68,0.12); color: var(--danger); border-color: rgba(239,68,68,0.3); }
|
||||
.tag.format-mp3 { background: rgba(0,212,255,0.12); color: var(--accent); border-color: rgba(0,212,255,0.3); }
|
||||
.empty { color: var(--text-mute); font-size: 12px; text-align: center; padding: 20px; }
|
||||
.refresh-time { color: var(--text-mute); font-size: 11px; }
|
||||
|
||||
.error-box {
|
||||
background: rgba(255,71,87,0.1); border: 1px solid rgba(255,71,87,0.3);
|
||||
border-radius: 12px; padding: 20px; color: #ff4757; text-align: center;
|
||||
}
|
||||
.loading { text-align: center; padding: 40px; color: #888; }
|
||||
.metrics-frame {
|
||||
width: 100%; height: 600px; border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 12px; background: #0e0e1a; margin-top: 20px;
|
||||
background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3);
|
||||
border-radius: 8px; padding: 12px 16px; color: var(--danger);
|
||||
margin-bottom: 16px; font-size: 13px;
|
||||
}
|
||||
|
||||
.row-flex { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="header">
|
||||
<h1>TTS 服务监控</h1>
|
||||
<div class="version">{{ healthData.service || 'Loading...' }} - {{ healthData.version || '' }}</div>
|
||||
<div class="actions">
|
||||
<button class="btn" @click="fetchHealth(true)" :disabled="loading">
|
||||
{{ loading ? '刷新中...' : '刷新状态' }}
|
||||
<div class="header-left">
|
||||
<div class="logo">TTS</div>
|
||||
<div>
|
||||
<h1>火山 TTS 服务监控</h1>
|
||||
<div class="sub">{{ health.service || '' }} · {{ health.version || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-flex">
|
||||
<span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'">
|
||||
<span class="status-dot" :class="{ live: health.status === 'ok' }"></span>
|
||||
{{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }}
|
||||
</span>
|
||||
<button class="btn btn-primary" @click="refresh" :disabled="loading">
|
||||
{{ loading ? "刷新中..." : "刷新" }}
|
||||
</button>
|
||||
<a class="btn secondary" href="/metrics" target="_blank">查看 Prometheus 指标</a>
|
||||
<span class="refresh-time">{{ lastRefresh }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-box">{{ error }}</div>
|
||||
|
||||
<div v-if="healthData.status">
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="dot" :class="{ error: healthData.status !== 'ok' }"></span>
|
||||
服务状态
|
||||
</div>
|
||||
<div class="stat-value">{{ healthData.status === 'ok' ? '正常运行' : '配置错误' }}</div>
|
||||
<div class="stat-label">运行时长: {{ formatUptime(healthData.uptime) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>配置状态</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">环境变量</span>
|
||||
<span class="info-value" :class="healthData.config_status?.all_required_vars_set ? 'success' : 'error'">
|
||||
{{ healthData.config_status?.all_required_vars_set ? '已配置' : '未配置' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">启动时间</span>
|
||||
<span class="info-value">{{ healthData.start_time || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">详细指标</span>
|
||||
<a class="info-value" href="/metrics" target="_blank" style="color:#00d4ff">/metrics</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>内存使用</div>
|
||||
<div class="memory-stat">
|
||||
<div>
|
||||
<div class="value">{{ formatBytes(healthData.memory?.heap_alloc) }}</div>
|
||||
<div class="label">Heap Alloc</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="value">{{ formatBytes(healthData.memory?.heap_inuse) }}</div>
|
||||
<div class="label">Heap Inuse</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="value">{{ healthData.memory?.goroutines || 0 }}</div>
|
||||
<div class="label">Goroutines</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">运行时长</div>
|
||||
<div class="stat-value">{{ formatUptime(health.uptime) }}</div>
|
||||
<div class="stat-foot">启动于 {{ formatTime(health.start_time) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="dot"></span>指标预览 (Prometheus 文本格式)</div>
|
||||
<iframe class="metrics-frame" src="/metrics"></iframe>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总请求数</div>
|
||||
<div class="stat-value accent">{{ totalRequests }}</div>
|
||||
<div class="stat-foot">自启动以来</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">成功率</div>
|
||||
<div class="stat-value" :class="successRateClass">{{ successRate }}</div>
|
||||
<div class="stat-foot">{{ okRequests }} 成功 / {{ errRequests }} 失败</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">当前并发</div>
|
||||
<div class="stat-value">{{ concurrencyActive }}</div>
|
||||
<div class="stat-foot">在飞请求数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Goroutines</div>
|
||||
<div class="stat-value">{{ health.memory?.goroutines || 0 }}</div>
|
||||
<div class="stat-foot">Go 运行时</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">堆内存</div>
|
||||
<div class="stat-value">{{ formatBytes(health.memory?.heap_alloc) }}</div>
|
||||
<div class="stat-foot">已分配 / 容量 {{ formatBytes(health.memory?.heap_inuse) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !healthData.status" class="loading">加载中...</div>
|
||||
<div class="section">
|
||||
<div class="section-title">请求 & 流量</div>
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">请求数</div>
|
||||
<div class="card-desc">tts_request_total · 按 status / format / speaker 拆分</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table" v-if="requestRows.length">
|
||||
<thead><tr><th>状态</th><th>格式</th><th>音色</th><th>次数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in requestRows" :key="r.key">
|
||||
<td><span class="tag" :class="r.statusClass">{{ r.status }}</span></td>
|
||||
<td><span class="tag" v-if="r.format" :class="`format-${r.format}`">{{ r.format }}</span><span v-else class="tag">-</span></td>
|
||||
<td class="label-cell">{{ r.speaker || "-" }}</td>
|
||||
<td class="num" :class="r.statusClass">{{ r.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">端到端延迟</div>
|
||||
<div class="card-desc">tts_request_duration_seconds · 50/95/99 百分位</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table" v-if="durationRows.length">
|
||||
<thead><tr><th>状态</th><th>格式</th><th>p50</th><th>p95</th><th>p99</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in durationRows" :key="r.key">
|
||||
<td><span class="tag" :class="r.statusClass">{{ r.status }}</span></td>
|
||||
<td><span class="tag" v-if="r.format" :class="`format-${r.format}`">{{ r.format }}</span><span v-else class="tag">-</span></td>
|
||||
<td class="num">{{ r.p50 }}</td>
|
||||
<td class="num">{{ r.p95 }}</td>
|
||||
<td class="num warning">{{ r.p99 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">上游火山 API</div>
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">上游调用</div>
|
||||
<div class="card-desc">tts_upstream_total · 按 status / format 拆分</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table" v-if="upstreamRows.length">
|
||||
<thead><tr><th>状态</th><th>格式</th><th>次数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in upstreamRows" :key="r.key">
|
||||
<td><span class="tag" :class="r.statusClass">{{ r.status }}</span></td>
|
||||
<td><span class="tag" v-if="r.format" :class="`format-${r.format}`">{{ r.format }}</span><span v-else class="tag">-</span></td>
|
||||
<td class="num" :class="r.statusClass">{{ r.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">首字节耗时 (TTFB)</div>
|
||||
<div class="card-desc">tts_upstream_first_byte_seconds · 按格式拆分</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table" v-if="ttfbRows.length">
|
||||
<thead><tr><th>格式</th><th>p50</th><th>p95</th><th>p99</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in ttfbRows" :key="r.format">
|
||||
<td><span class="tag" :class="`format-${r.format}`">{{ r.format }}</span></td>
|
||||
<td class="num">{{ r.p50 }}</td>
|
||||
<td class="num">{{ r.p95 }}</td>
|
||||
<td class="num warning">{{ r.p99 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">流量统计</div>
|
||||
<div class="card-desc">chunks & 音频字节数</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table" v-if="trafficRows.length">
|
||||
<thead><tr><th>格式</th><th>音频分片</th><th>音频字节</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in trafficRows" :key="r.format">
|
||||
<td><span class="tag" :class="`format-${r.format}`">{{ r.format }}</span></td>
|
||||
<td class="num">{{ r.chunks }}</td>
|
||||
<td class="num success">{{ r.audioBytes }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">上游错误</div>
|
||||
<div class="card-desc">tts_upstream_errors_total · 按错误码聚合</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table" v-if="errorRows.length">
|
||||
<thead><tr><th>错误码</th><th>次数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in errorRows" :key="r.code">
|
||||
<td class="label-cell">{{ r.code }}</td>
|
||||
<td class="num danger">{{ r.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无错误</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">限流 & 计费</div>
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">被拒请求</div>
|
||||
<div class="card-desc">限流 / 并发 / 鉴权失败</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="label-cell">tts_ratelimit_rejected_total</td>
|
||||
<td class="num" :class="{ danger: rateLimitRejected > 0 }">{{ rateLimitRejected }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">tts_concurrency_rejected_total</td>
|
||||
<td class="num" :class="{ danger: concurrencyRejected > 0 }">{{ concurrencyRejected }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">tts_auth_failed_total</td>
|
||||
<td class="num" :class="{ danger: authFailed > 0 }">{{ authFailed }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">计费字符</div>
|
||||
<div class="card-desc">tts_usage_text_words_total · 按模型拆分</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table" v-if="usageRows.length">
|
||||
<thead><tr><th>模型</th><th>字符数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in usageRows" :key="r.model">
|
||||
<td class="label-cell">{{ r.model }}</td>
|
||||
<td class="num success">{{ r.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, onMounted } = Vue;
|
||||
const { createApp, ref, computed, onMounted } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const healthData = ref({});
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const deepUpdate = (target, source) => {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (!target[key]) target[key] = {};
|
||||
deepUpdate(target[key], source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHealth = async (showLoading = false) => {
|
||||
if (showLoading) loading.value = true;
|
||||
try {
|
||||
const response = await axios.get('/health');
|
||||
deepUpdate(healthData.value, response.data);
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
if (e.response && e.response.data) {
|
||||
deepUpdate(healthData.value, e.response.data);
|
||||
if (e.response.status === 503) {
|
||||
error.value = '服务配置异常,请检查环境变量配置';
|
||||
} else {
|
||||
error.value = '服务异常: ' + (e.message || '未知错误');
|
||||
}
|
||||
} else {
|
||||
error.value = '无法获取服务状态: ' + (e.message || '未知错误');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
if (!seconds) return '-';
|
||||
const s = parseInt(seconds);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}天 ${h}小时`;
|
||||
if (h > 0) return `${h}小时 ${m}分钟`;
|
||||
return `${m}分钟`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchHealth(true);
|
||||
setInterval(() => fetchHealth(false), 10000);
|
||||
function parseProm(text) {
|
||||
const out = {};
|
||||
text.split("\n").forEach(line => {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith("#")) return;
|
||||
const m = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{([^}]*)\})?\s+(.+)$/);
|
||||
if (!m) return;
|
||||
const name = m[1];
|
||||
const labels = {};
|
||||
if (m[3]) {
|
||||
m[3].split(",").forEach(p => {
|
||||
const kv = p.match(/^(\w+)="([^"]*)"$/);
|
||||
if (kv) labels[kv[1]] = kv[2];
|
||||
});
|
||||
|
||||
return { healthData, loading, error, fetchHealth, formatBytes, formatUptime };
|
||||
}
|
||||
}).mount('#app');
|
||||
const value = parseFloat(m[4]);
|
||||
if (!out[name]) out[name] = [];
|
||||
out[name].push({ labels, value });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function histogramStats(samples) {
|
||||
// bucket le="+Inf" 的值是总数
|
||||
let total = 0;
|
||||
const buckets = [];
|
||||
for (const s of samples) {
|
||||
if (s.labels.le === "+Inf") total = s.value;
|
||||
else buckets.push({ le: parseFloat(s.labels.le), count: s.value });
|
||||
}
|
||||
buckets.sort((a, b) => a.le - b.le);
|
||||
function quantile(q) {
|
||||
if (!total) return 0;
|
||||
const target = total * q;
|
||||
let prevCount = 0;
|
||||
let prevLe = 0;
|
||||
for (const b of buckets) {
|
||||
if (b.count >= target) {
|
||||
if (b.count === prevCount) return prevLe;
|
||||
return prevLe + (b.le - prevLe) * (target - prevCount) / (b.count - prevCount);
|
||||
}
|
||||
prevCount = b.count;
|
||||
prevLe = b.le;
|
||||
}
|
||||
return buckets.length ? buckets[buckets.length - 1].le : 0;
|
||||
}
|
||||
return { p50: quantile(0.5), p95: quantile(0.95), p99: quantile(0.99) };
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === "ok" || !status) return "status-ok";
|
||||
if (status.includes("error") || status.includes("reject") || status === "internal_error") return "status-error";
|
||||
return "status-error";
|
||||
}
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n === undefined || n === null) return "-";
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
|
||||
return String(Math.round(n));
|
||||
}
|
||||
function fmtSec(s) {
|
||||
if (!s || s === 0) return "0s";
|
||||
if (s < 1) return (s * 1000).toFixed(0) + "ms";
|
||||
return s.toFixed(2) + "s";
|
||||
}
|
||||
function fmtBytes(b) {
|
||||
if (!b) return "0 B";
|
||||
const k = 1024, sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(b) / Math.log(k));
|
||||
return (b / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
|
||||
}
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const health = ref({});
|
||||
const metrics = ref({});
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const lastRefresh = ref("-");
|
||||
|
||||
const totalRequests = computed(() => {
|
||||
const arr = metrics.value["tts_request_total"] || [];
|
||||
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
|
||||
});
|
||||
const okRequests = computed(() => {
|
||||
const arr = metrics.value["tts_request_total"] || [];
|
||||
return fmtNum(arr.filter(s => s.labels.status === "ok").reduce((a, b) => a + b.value, 0));
|
||||
});
|
||||
const errRequests = computed(() => {
|
||||
const arr = metrics.value["tts_request_total"] || [];
|
||||
return fmtNum(arr.filter(s => s.labels.status !== "ok").reduce((a, b) => a + b.value, 0));
|
||||
});
|
||||
const successRate = computed(() => {
|
||||
const total = (metrics.value["tts_request_total"] || []).reduce((a, b) => a + b.value, 0);
|
||||
if (!total) return "-";
|
||||
const ok = (metrics.value["tts_request_total"] || []).filter(s => s.labels.status === "ok").reduce((a, b) => a + b.value, 0);
|
||||
return (ok / total * 100).toFixed(1) + "%";
|
||||
});
|
||||
const successRateClass = computed(() => {
|
||||
const total = (metrics.value["tts_request_total"] || []).reduce((a, b) => a + b.value, 0);
|
||||
if (!total) return "";
|
||||
const ok = (metrics.value["tts_request_total"] || []).filter(s => s.labels.status === "ok").reduce((a, b) => a + b.value, 0);
|
||||
const r = ok / total;
|
||||
if (r >= 0.95) return "success";
|
||||
if (r >= 0.8) return "warning";
|
||||
return "danger";
|
||||
});
|
||||
const concurrencyActive = computed(() => {
|
||||
const arr = metrics.value["tts_concurrency_active"] || [];
|
||||
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
|
||||
});
|
||||
const rateLimitRejected = computed(() => {
|
||||
const arr = metrics.value["tts_ratelimit_rejected_total"] || [];
|
||||
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
|
||||
});
|
||||
const concurrencyRejected = computed(() => {
|
||||
const arr = metrics.value["tts_concurrency_rejected_total"] || [];
|
||||
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
|
||||
});
|
||||
const authFailed = computed(() => {
|
||||
const arr = metrics.value["tts_auth_failed_total"] || [];
|
||||
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
|
||||
});
|
||||
|
||||
const requestRows = computed(() => {
|
||||
const arr = metrics.value["tts_request_total"] || [];
|
||||
return arr
|
||||
.map(s => ({
|
||||
key: JSON.stringify(s.labels),
|
||||
status: s.labels.status || "-",
|
||||
format: s.labels.format || "",
|
||||
speaker: s.labels.speaker || "",
|
||||
value: fmtNum(s.value),
|
||||
statusClass: statusClass(s.labels.status)
|
||||
}))
|
||||
.sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
|
||||
});
|
||||
|
||||
const durationRows = computed(() => {
|
||||
const groups = {};
|
||||
(metrics.value["tts_request_duration_seconds"] || []).forEach(s => {
|
||||
const key = `${s.labels.status || "-"}|${s.labels.format || ""}`;
|
||||
if (!groups[key]) groups[key] = { status: s.labels.status, format: s.labels.format, samples: [] };
|
||||
groups[key].samples.push(s);
|
||||
});
|
||||
return Object.values(groups).map(g => {
|
||||
const stats = histogramStats(g.samples);
|
||||
return {
|
||||
key: `${g.status}|${g.format}`,
|
||||
status: g.status || "-",
|
||||
format: g.format || "",
|
||||
p50: fmtSec(stats.p50),
|
||||
p95: fmtSec(stats.p95),
|
||||
p99: fmtSec(stats.p99),
|
||||
statusClass: statusClass(g.status)
|
||||
};
|
||||
}).sort((a, b) => a.format.localeCompare(b.format));
|
||||
});
|
||||
|
||||
const upstreamRows = computed(() => {
|
||||
const arr = metrics.value["tts_upstream_total"] || [];
|
||||
return arr.map(s => ({
|
||||
key: JSON.stringify(s.labels),
|
||||
status: s.labels.status || "-",
|
||||
format: s.labels.format || "",
|
||||
value: fmtNum(s.value),
|
||||
statusClass: statusClass(s.labels.status)
|
||||
})).sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
|
||||
});
|
||||
|
||||
const ttfbRows = computed(() => {
|
||||
const groups = {};
|
||||
(metrics.value["tts_upstream_first_byte_seconds"] || []).forEach(s => {
|
||||
const key = s.labels.format || "-";
|
||||
if (!groups[key]) groups[key] = { format: s.labels.format, samples: [] };
|
||||
groups[key].samples.push(s);
|
||||
});
|
||||
return Object.values(groups).map(g => {
|
||||
const stats = histogramStats(g.samples);
|
||||
return { format: g.format || "-", p50: fmtSec(stats.p50), p95: fmtSec(stats.p95), p99: fmtSec(stats.p99) };
|
||||
}).sort((a, b) => a.format.localeCompare(b.format));
|
||||
});
|
||||
|
||||
const trafficRows = computed(() => {
|
||||
const groups = {};
|
||||
(metrics.value["tts_upstream_chunks_total"] || []).forEach(s => {
|
||||
const key = s.labels.format || "-";
|
||||
if (!groups[key]) groups[key] = { format: s.labels.format, chunks: 0, audioBytes: 0 };
|
||||
groups[key].chunks += s.value;
|
||||
});
|
||||
(metrics.value["tts_upstream_audio_bytes_total"] || []).forEach(s => {
|
||||
const key = s.labels.format || "-";
|
||||
if (!groups[key]) groups[key] = { format: s.labels.format, chunks: 0, audioBytes: 0 };
|
||||
groups[key].audioBytes += s.value;
|
||||
});
|
||||
return Object.values(groups).map(g => ({
|
||||
format: g.format || "-",
|
||||
chunks: fmtNum(g.chunks),
|
||||
audioBytes: fmtBytes(g.audioBytes)
|
||||
})).sort((a, b) => a.format.localeCompare(b.format));
|
||||
});
|
||||
|
||||
const errorRows = computed(() => {
|
||||
const arr = metrics.value["tts_upstream_errors_total"] || [];
|
||||
return arr.map(s => ({ code: s.labels.code || "-", value: fmtNum(s.value) }))
|
||||
.sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
|
||||
});
|
||||
|
||||
const usageRows = computed(() => {
|
||||
const arr = metrics.value["tts_usage_text_words_total"] || [];
|
||||
return arr.map(s => ({ model: s.labels.model || "-", value: fmtNum(s.value) }))
|
||||
.sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
|
||||
});
|
||||
|
||||
const fetchHealth = async () => {
|
||||
try {
|
||||
const r = await axios.get("/health");
|
||||
health.value = r.data;
|
||||
} catch (e) {
|
||||
error.value = "无法获取健康状态: " + (e.message || "未知错误");
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const r = await axios.get("/metrics");
|
||||
metrics.value = parseProm(r.data);
|
||||
} catch (e) {
|
||||
// 不覆盖 error,让 health 错误更显眼
|
||||
console.warn("metrics fetch failed:", e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
loading.value = true;
|
||||
await Promise.all([fetchHealth(), fetchMetrics()]);
|
||||
loading.value = false;
|
||||
lastRefresh.value = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return "-";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
} catch { return iso; }
|
||||
};
|
||||
|
||||
const formatUptime = (s) => {
|
||||
if (!s) return "-";
|
||||
const sec = parseInt(s);
|
||||
if (isNaN(sec)) return s;
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
};
|
||||
|
||||
const formatBytes = (b) => {
|
||||
if (!b) return "0 B";
|
||||
const k = 1024, sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(b) / Math.log(k));
|
||||
return (b / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
setInterval(refresh, 10000);
|
||||
});
|
||||
|
||||
return {
|
||||
health, metrics, loading, error, lastRefresh,
|
||||
totalRequests, okRequests, errRequests, successRate, successRateClass,
|
||||
concurrencyActive, rateLimitRejected, concurrencyRejected, authFailed,
|
||||
requestRows, durationRows, upstreamRows, ttfbRows, trafficRows, errorRows, usageRows,
|
||||
refresh, formatBytes, formatUptime, formatTime
|
||||
};
|
||||
}
|
||||
}).mount("#app");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,6 +9,7 @@ package middleware
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/volcano-tts/tts-api/metrics"
|
||||
)
|
||||
@@ -16,6 +17,11 @@ import (
|
||||
// RateLimitWithMetrics 是 middleware.RateLimit 的可埋点版本。
|
||||
func RateLimitWithMetrics(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 仅对 /v1/ 下的业务请求限流,/health /metrics /dashboard 等监控路径不限流
|
||||
if !strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
clientIP := GetClientIP(r)
|
||||
if !GlobalRateLimiter.Allow(clientIP) {
|
||||
log.Printf("警告: 已超过IP速率限制,拒绝请求 - 客户端IP: %s", clientIP)
|
||||
@@ -29,6 +35,11 @@ func RateLimitWithMetrics(next http.Handler) http.Handler {
|
||||
// ConcurrencyLimitWithMetrics 是 middleware.ConcurrencyLimit 的可埋点版本。
|
||||
func ConcurrencyLimitWithMetrics(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 仅对 /v1/ 下的业务请求统计并发和加锁,监控路径不占用并发槽位
|
||||
if !strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case ConcurrencySem <- struct{}{}:
|
||||
metrics.ConcurrencyActive.Inc(nil)
|
||||
|
||||
Reference in New Issue
Block a user