Files
meshtastic_mqtt_server/meshmap_frontend/src/components/AdminDashboard.vue
T

520 lines
16 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 { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { disconnectAndBlockMqttClient, getAdminMqttStatus, getAdminRuntimeSettings, updateAdminRuntimeSettings } from '../api'
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
import type { AdminMqttClient, 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('')
const clientActionMessage = ref('')
const clientActionError = ref('')
const pendingClient = ref<AdminMqttClient | null>(null)
const clientActionInProgress = ref(false)
let timer: number | undefined
type ClientSortKey = 'client_id' | 'username' | 'listener' | 'remote_addr' | 'packets_in' | 'packets_out'
type SortDir = 'asc' | 'desc'
const clientSortKey = ref<ClientSortKey>('client_id')
const clientSortDir = ref<SortDir>('asc')
function toggleClientSort(key: ClientSortKey) {
if (clientSortKey.value === key) {
clientSortDir.value = clientSortDir.value === 'asc' ? 'desc' : 'asc'
} else {
clientSortKey.value = key
clientSortDir.value = 'asc'
}
}
const sortedClients = computed<AdminMqttClient[]>(() => {
const list = status.value?.clients ? [...status.value.clients] : []
const key = clientSortKey.value
const dir = clientSortDir.value === 'asc' ? 1 : -1
list.sort((a, b) => {
const av = a[key]
const bv = b[key]
if (typeof av === 'number' && typeof bv === 'number') {
return (av - bv) * dir
}
return String(av ?? '').localeCompare(String(bv ?? '')) * dir
})
return list
})
function sortIndicator(key: ClientSortKey): string {
if (clientSortKey.value !== key) return ''
return clientSortDir.value === 'asc' ? '▲' : '▼'
}
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
}
}
function requestDisconnectAndBlock(client: AdminMqttClient) {
clientActionMessage.value = ''
clientActionError.value = ''
pendingClient.value = client
}
function cancelClientAction() {
if (clientActionInProgress.value) {
return
}
pendingClient.value = null
}
async function confirmClientAction(payload: { reason?: string }) {
const client = pendingClient.value
if (!client) {
return
}
const reason = payload.reason?.trim()
if (!reason) {
return
}
clientActionInProgress.value = true
clientActionError.value = ''
clientActionMessage.value = ''
try {
const result = await disconnectAndBlockMqttClient(client.client_id, { reason })
const ipText = result.ip_value || client.remote_addr || '-'
const ruleText = result.ip_rule_created ? '已新增屏蔽规则' : '屏蔽规则已存在'
const disconnectText = result.disconnected ? '已断开连接' : '连接已不存在'
clientActionMessage.value = `${disconnectText}IP ${ipText} ${ruleText}。`
pendingClient.value = null
await refreshStatus()
} catch (err) {
clientActionError.value = err instanceof Error ? err.message : String(err)
} finally {
clientActionInProgress.value = false
}
}
const pendingClientLabel = computed(() => {
const client = pendingClient.value
if (!client) {
return ''
}
const ip = client.remote_addr || '-'
return `Client ID: ${client.client_id || '-'}\n远端: ${ip}`
})
const clientActionMessageText = computed(() => {
const client = pendingClient.value
if (!client) {
return ''
}
const ip = client.remote_addr ? client.remote_addr.replace(/:\d+$/, '') : '该客户端的 IP'
return `确定要立即断开 MQTT 客户端 “${client.client_id || '-'}” 并将 ${ip} 加入 IP 屏蔽表吗?此操作会立即生效,请输入屏蔽原因。\n\n${pendingClientLabel.value}`
})
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 class="sortable" @click="toggleClientSort('client_id')">Client ID <span class="sort-indicator">{{ sortIndicator('client_id') }}</span></th>
<th class="sortable" @click="toggleClientSort('username')">Username <span class="sort-indicator">{{ sortIndicator('username') }}</span></th>
<th class="sortable" @click="toggleClientSort('listener')">Listener <span class="sort-indicator">{{ sortIndicator('listener') }}</span></th>
<th class="sortable" @click="toggleClientSort('remote_addr')">Remote Addr <span class="sort-indicator">{{ sortIndicator('remote_addr') }}</span></th>
<th class="sortable" @click="toggleClientSort('packets_in')">客户端服务器 <span class="sort-indicator">{{ sortIndicator('packets_in') }}</span></th>
<th class="sortable" @click="toggleClientSort('packets_out')">服务器客户端 <span class="sort-indicator">{{ sortIndicator('packets_out') }}</span></th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="client in sortedClients" :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>
<td>
<button
type="button"
class="client-danger-action"
:disabled="clientActionInProgress"
@click="requestDisconnectAndBlock(client)"
>断开并屏蔽 IP</button>
</td>
</tr>
</tbody>
</table>
<div v-if="!status?.clients?.length" class="empty">暂无客户端连接</div>
<p v-if="clientActionMessage" class="success client-action-feedback">{{ clientActionMessage }}</p>
<p v-if="clientActionError" class="error client-action-feedback">{{ clientActionError }}</p>
</div>
</div>
<ConfirmDeleteModal
:open="!!pendingClient"
title="确认断开并屏蔽 IP"
:message="clientActionMessageText"
confirm-text="断开并屏蔽"
:require-reason="true"
reason-label="屏蔽原因"
reason-placeholder="请输入屏蔽原因必填"
@cancel="cancelClientAction"
@confirm="confirmClientAction"
/>
</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;
}
}
.node-table th.sortable {
cursor: pointer;
user-select: none;
}
.node-table th.sortable:hover {
color: var(--color-primary);
}
.node-table th.sortable .sort-indicator {
display: inline-block;
margin-left: 4px;
font-size: 11px;
color: var(--color-primary);
}
.client-danger-action {
border: 1px solid color-mix(in srgb, var(--color-danger, #d04848) 36%, white);
border-radius: 6px;
padding: 4px 10px;
background: color-mix(in srgb, var(--color-danger, #d04848) 10%, white);
color: var(--color-danger, #d04848);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease;
}
.client-danger-action:hover:not(:disabled) {
background: var(--color-danger, #d04848);
color: #fff;
}
.client-danger-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.client-action-feedback {
margin: 0.5rem 0 0;
white-space: pre-line;
}
</style>