Files
meshtastic_mqtt_server/meshmap_frontend/src/components/AdminDashboard.vue
T
kevinandClaude 49a287e6e7 feat: console_log.meshtastic 控制 packet 控制台输出;新增每客户端报文统计
- ConsoleLogConfig 增加 Meshtastic 字段(默认 true);旧配置自动补齐
- meshtasticFilterHook 增加 packetConsoleLog;OnPublish 中根据开关调用
  新的 printMeshtasticRecord 输出可读单行(key=value、按 type 着色),
  替代原来的 JSON dump;事件型 printJSON 调用保持不变
- 新增 internal/mqttforward/ClientStats,按 client_id 累计 in/out 报文计数
- meshtasticFilterHook 多挂 OnPacketRead / OnPacketSent / OnDisconnect
  事件,用于增减计数;Provides() 同步声明
- AdminMQTTClient JSON 视图删除 RemoteHost/RemotePort,新增
  packets_in / packets_out;前端 types.ts、AdminDashboard.vue 同步更新

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-19 17:09:00 +08:00

356 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { getAdminMqttStatus, getAdminRuntimeSettings, updateAdminRuntimeSettings } from '../api'
import type { AdminMqttStatus, AdminRuntimeSettings } from '../types'
const status = ref<AdminMqttStatus | null>(null)
const runtimeSettings = ref<AdminRuntimeSettings | null>(null)
const loading = ref(false)
const settingsLoading = ref(false)
const error = ref('')
const settingsError = ref('')
const settingsMessage = ref('')
let timer: number | undefined
function formatUptime(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours}h ${minutes}m ${secs}s`
}
async function refreshStatus() {
loading.value = true
error.value = ''
try {
status.value = await getAdminMqttStatus()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function refreshRuntimeSettings() {
settingsLoading.value = true
settingsError.value = ''
try {
const response = await getAdminRuntimeSettings()
runtimeSettings.value = response.item
} catch (err) {
settingsError.value = err instanceof Error ? err.message : String(err)
} finally {
settingsLoading.value = false
}
}
async function saveEncryptedForwarding(value: boolean) {
if (!runtimeSettings.value) {
return
}
const previous = runtimeSettings.value.allow_encrypted_forwarding
runtimeSettings.value.allow_encrypted_forwarding = value
settingsLoading.value = true
settingsError.value = ''
settingsMessage.value = ''
try {
const response = await updateAdminRuntimeSettings({ allow_encrypted_forwarding: value })
runtimeSettings.value = response.item
settingsMessage.value = '设置已保存'
} catch (err) {
runtimeSettings.value.allow_encrypted_forwarding = previous
settingsError.value = err instanceof Error ? err.message : String(err)
} finally {
settingsLoading.value = false
}
}
onMounted(() => {
refreshStatus()
refreshRuntimeSettings()
timer = window.setInterval(refreshStatus, 5000)
})
onBeforeUnmount(() => {
if (timer !== undefined) {
window.clearInterval(timer)
}
})
</script>
<template>
<section class="admin-dashboard">
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Admin</p>
<h2>MQTT 服务状态</h2>
</div>
<div class="admin-actions">
<button @click="refreshStatus" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="!status" class="empty">正在加载 MQTT 状态...</div>
<div v-else class="admin-status-grid">
<div><span>运行状态</span><strong>{{ status.running ? '运行中' : '未运行' }}</strong></div>
<div><span>监听地址</span><strong>{{ status.address || '-' }}</strong></div>
<div><span>TLS</span><strong>{{ status.tls ? '启用' : '未启用' }}</strong></div>
<div><span>Uptime</span><strong>{{ formatUptime(status.uptime || 0) }}</strong></div>
<div><span>当前连接</span><strong>{{ status.clients_connected }}</strong></div>
<div><span>订阅数</span><strong>{{ status.subscriptions }}</strong></div>
<div><span>转发消息</span><strong>{{ status.messages_sent }}</strong></div>
<div><span>数据库队列</span><strong>{{ status.db_write_queue_length }}</strong></div>
<a class="status-card-link" href="/admin/discard_details"><span>丢弃消息</span><strong>{{ status.messages_dropped }}</strong></a>
<div><span>收到包</span><strong>{{ status.packets_received }}</strong></div>
<div><span>发送包</span><strong>{{ status.packets_sent }}</strong></div>
</div>
</div>
<div class="panel admin-status-panel mqtt-control-panel">
<div class="panel-header control-header">
<div class="control-title">
<div>
<p class="eyebrow">MQTT Forwarding</p>
<h2>MQTT 转发控制</h2>
</div>
</div>
<span class="control-badge" :class="{ active: runtimeSettings?.allow_encrypted_forwarding }">
{{ runtimeSettings?.allow_encrypted_forwarding ? '加密包放行' : '默认拦截' }}
</span>
</div>
<div class="control-body">
<div class="control-copy">
<h3>加密转发</h3>
<p>
控制 Broker 在无法解密 Meshtastic 加密包时是否仍允许转发关闭时保持当前行为无法解密的加密包会被丢弃并记录到丢弃详情
</p>
</div>
<div v-if="!runtimeSettings" class="empty control-empty">正在加载转发设置...</div>
<label v-else class="switch-card" :class="{ enabled: runtimeSettings.allow_encrypted_forwarding, saving: settingsLoading }">
<span class="switch-text">
<strong>允许无法解密的加密包继续转发</strong>
<small>{{ runtimeSettings.allow_encrypted_forwarding ? '已开启,原始 payload 将继续转发' : '已关闭,无法解密时会拒绝转发' }}</small>
</span>
<input
type="checkbox"
:checked="runtimeSettings.allow_encrypted_forwarding"
:disabled="settingsLoading"
@change="saveEncryptedForwarding(($event.target as HTMLInputElement).checked)"
/>
<span class="switch-toggle" aria-hidden="true"></span>
</label>
</div>
<p v-if="settingsError" class="error">{{ settingsError }}</p>
<p v-if="settingsMessage" class="success">{{ settingsMessage }}</p>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Clients</p>
<h2>MQTT 客户端</h2>
</div>
<span class="badge">{{ status?.clients?.length ?? 0 }}</span>
</div>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>Client ID</th>
<th>Username</th>
<th>Listener</th>
<th>Remote Addr</th>
<th>客户端服务器</th>
<th>服务器客户端</th>
</tr>
</thead>
<tbody>
<tr v-for="client in status?.clients || []" :key="client.client_id">
<td>{{ client.client_id || '-' }}</td>
<td>{{ client.username || '-' }}</td>
<td>{{ client.listener || '-' }}</td>
<td>{{ client.remote_addr || '-' }}</td>
<td>{{ client.packets_in ?? 0 }}</td>
<td>{{ client.packets_out ?? 0 }}</td>
</tr>
</tbody>
</table>
<div v-if="!status?.clients?.length" class="empty">暂无客户端连接</div>
</div>
</div>
</section>
</template>
<style scoped>
.mqtt-control-panel {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 1rem;
border: 1px solid var(--color-border);
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
}
.control-header {
position: relative;
align-items: flex-start;
}
.control-title {
display: flex;
align-items: center;
gap: 0.85rem;
}
.control-badge {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 6px 12px;
color: var(--color-muted);
font-size: 12px;
font-weight: 800;
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
}
.control-badge.active {
border-color: color-mix(in srgb, var(--color-success) 36%, white);
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
background: var(--color-success-soft);
}
.control-body {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr);
gap: 1rem;
align-items: stretch;
}
.control-copy,
.switch-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
box-shadow: var(--shadow-sm);
}
.control-copy {
padding: 1rem;
}
.control-copy h3 {
margin: 0 0 0.45rem;
color: var(--color-heading);
font-size: 18px;
}
.control-copy p {
margin: 0;
color: var(--color-muted);
line-height: 1.7;
}
.control-empty {
align-self: center;
}
.switch-card {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 108px;
padding: 1rem;
color: var(--color-text);
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease;
}
.switch-card:hover {
transform: translateY(-1px);
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.switch-card.enabled {
border-color: color-mix(in srgb, var(--color-success) 42%, white);
background: var(--color-success-soft);
}
.switch-card.saving {
cursor: wait;
opacity: 0.76;
}
.switch-card input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.switch-text {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.switch-text strong {
color: var(--color-heading);
font-size: 15px;
}
.switch-text small {
color: var(--color-muted);
font-size: 12px;
line-height: 1.45;
}
.switch-toggle {
position: relative;
flex: 0 0 auto;
width: 54px;
height: 30px;
border-radius: 999px;
background: var(--color-border-strong);
box-shadow: inset 0 2px 4px rgba(47, 52, 50, 0.12);
transition: background-color 0.16s ease;
}
.switch-toggle::after {
content: '';
position: absolute;
top: 4px;
left: 4px;
width: 22px;
height: 22px;
border-radius: 999px;
background: #fff;
box-shadow: 0 4px 10px rgba(47, 52, 50, 0.18);
transition: transform 0.16s ease;
}
.switch-card.enabled .switch-toggle {
background: var(--color-success);
}
.switch-card.enabled .switch-toggle::after {
transform: translateX(24px);
}
@media (max-width: 820px) {
.control-body {
grid-template-columns: 1fr;
}
.control-header {
gap: 0.75rem;
}
}
</style>