Files
meshtastic_mqtt_server/meshmap_frontend/src/components/AdminMqttForward.vue
T
2026-06-05 18:49:30 +08:00

924 lines
27 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 {
createMQTTForwarder,
createMQTTForwardTopic,
deleteMQTTForwarder,
deleteMQTTForwardTopic,
getMQTTForwarders,
getMQTTForwardStatus,
getMQTTForwardTopics,
restartMQTTForwarder,
updateMQTTForwarder,
updateMQTTForwardTopic,
} from '../api'
import type {
MQTTForwarder,
MQTTForwarderPayload,
MQTTForwardRuntimeStatus,
MQTTForwardTopic,
MQTTForwardTopicPayload,
} from '../types'
const pageSize = 25
const topicPageSize = 100
const forwarders = ref<MQTTForwarder[]>([])
const topics = ref<Record<number, MQTTForwardTopic[]>>({})
const statuses = ref<Record<number, MQTTForwardRuntimeStatus>>({})
const edits = ref<Record<number, ForwarderEdit>>({})
const topicEdits = ref<Record<number, MQTTForwardTopicPayload>>({})
const expanded = ref<Record<number, boolean>>({})
const newTopics = ref<Record<number, MQTTForwardTopicPayload>>({})
const loading = ref(false)
const error = ref('')
const message = ref('')
const page = ref(1)
const total = ref(0)
let statusTimer: number | undefined
type ForwarderEdit = {
name: string
enabled: boolean
source_host: string
source_port: string
source_username: string
source_password: string
source_password_clear: boolean
source_client_id: string
source_tls: boolean
target_host: string
target_port: string
target_username: string
target_password: string
target_password_clear: boolean
target_client_id: string
target_tls: boolean
}
const newForwarder = ref<ForwarderEdit>({
name: '',
enabled: false,
source_host: 'mqtt.mess.host',
source_port: '1883',
source_username: '',
source_password: '',
source_password_clear: false,
source_client_id: '',
source_tls: false,
target_host: '127.0.0.1',
target_port: '1883',
target_username: '',
target_password: '',
target_password_clear: false,
target_client_id: '',
target_tls: false,
})
const canPrev = computed(() => page.value > 1)
const canNext = computed(() => page.value * pageSize < total.value || forwarders.value.length === pageSize)
function formatTime(value: string | null): string {
return value ? new Date(value).toLocaleString() : '-'
}
function defaultTopic(): MQTTForwardTopicPayload {
return { topic: 'msh/#', enabled: true, direction: 'source_to_target', source_prefix: '', target_prefix: '', qos: 0, retain: false }
}
function resetEdits(items: MQTTForwarder[]) {
edits.value = Object.fromEntries(items.map((item) => [item.id, forwarderToEdit(item)]))
for (const item of items) {
if (!newTopics.value[item.id]) {
newTopics.value[item.id] = defaultTopic()
}
}
}
function forwarderToEdit(item: MQTTForwarder): ForwarderEdit {
return {
name: item.name,
enabled: item.enabled,
source_host: item.source_host,
source_port: String(item.source_port),
source_username: item.source_username,
source_password: '',
source_password_clear: false,
source_client_id: item.source_client_id,
source_tls: item.source_tls,
target_host: item.target_host,
target_port: String(item.target_port),
target_username: item.target_username,
target_password: '',
target_password_clear: false,
target_client_id: item.target_client_id,
target_tls: item.target_tls,
}
}
function resetTopicEdits(forwarderId: number, items: MQTTForwardTopic[]) {
topics.value = { ...topics.value, [forwarderId]: items }
topicEdits.value = {
...topicEdits.value,
...Object.fromEntries(
items.map((item) => [
item.id,
{
topic: item.topic,
enabled: item.enabled,
direction: item.direction,
source_prefix: item.source_prefix,
target_prefix: item.target_prefix,
qos: item.qos,
retain: item.retain,
},
]),
),
}
}
function parsePort(value: string, label: string): number {
const parsed = Number.parseInt(value.trim(), 10)
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
throw new Error(`${label}必须是 1-65535`)
}
return parsed
}
function forwarderPayload(edit: ForwarderEdit, includePasswords: boolean): MQTTForwarderPayload {
if (!edit.name.trim()) {
throw new Error('名称不能为空')
}
if (!edit.source_host.trim()) {
throw new Error('源 Host 不能为空')
}
if (!edit.target_host.trim()) {
throw new Error('目标 Host 不能为空')
}
const payload: MQTTForwarderPayload = {
name: edit.name.trim(),
enabled: edit.enabled,
source_host: edit.source_host.trim(),
source_port: parsePort(edit.source_port, '源端口'),
source_username: edit.source_username.trim(),
source_client_id: edit.source_client_id.trim(),
source_tls: edit.source_tls,
target_host: edit.target_host.trim(),
target_port: parsePort(edit.target_port, '目标端口'),
target_username: edit.target_username.trim(),
target_client_id: edit.target_client_id.trim(),
target_tls: edit.target_tls,
}
if (includePasswords || edit.source_password.trim()) {
payload.source_password = edit.source_password
}
if (edit.source_password_clear) {
payload.source_password_clear = true
}
if (includePasswords || edit.target_password.trim()) {
payload.target_password = edit.target_password
}
if (edit.target_password_clear) {
payload.target_password_clear = true
}
return payload
}
function topicPayload(edit: MQTTForwardTopicPayload): MQTTForwardTopicPayload {
if (!edit.topic.trim()) {
throw new Error('TOPIC 不能为空')
}
return {
topic: edit.topic.trim(),
enabled: edit.enabled,
direction: edit.direction,
source_prefix: edit.source_prefix.trim(),
target_prefix: edit.target_prefix.trim(),
qos: Number(edit.qos),
retain: edit.retain,
}
}
async function refreshForwarders(targetPage = page.value) {
loading.value = true
error.value = ''
try {
const safePage = Math.max(1, targetPage)
const response = await getMQTTForwarders(pageSize, (safePage - 1) * pageSize)
forwarders.value = response.items
total.value = response.total ?? response.offset + response.items.length
page.value = safePage
resetEdits(response.items)
await refreshStatus()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function refreshStatus() {
try {
const response = await getMQTTForwardStatus()
statuses.value = Object.fromEntries(response.items.map((item) => [item.forwarder_id, item]))
} catch {
// Keep the page usable if status polling fails; CRUD calls will surface errors.
}
}
async function createForwarder() {
error.value = ''
message.value = ''
let payload: MQTTForwarderPayload
try {
payload = forwarderPayload(newForwarder.value, true)
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
return
}
loading.value = true
try {
await createMQTTForwarder(payload)
newForwarder.value.name = ''
newForwarder.value.source_password = ''
newForwarder.value.target_password = ''
message.value = 'MQTT 转发线程已新增'
await refreshForwarders(1)
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function saveForwarder(item: MQTTForwarder) {
error.value = ''
message.value = ''
const edit = edits.value[item.id]
if (!edit) return
let payload: MQTTForwarderPayload
try {
payload = forwarderPayload(edit, false)
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
return
}
loading.value = true
try {
await updateMQTTForwarder(item.id, payload)
message.value = 'MQTT 转发线程已保存'
await refreshForwarders()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function removeForwarder(item: MQTTForwarder) {
if (!window.confirm(`确定删除 MQTT 转发线程「${item.name}」吗?`)) return
error.value = ''
message.value = ''
loading.value = true
try {
await deleteMQTTForwarder(item.id)
message.value = 'MQTT 转发线程已删除'
await refreshForwarders(forwarders.value.length === 1 ? page.value - 1 : page.value)
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function restartForwarder(item: MQTTForwarder) {
error.value = ''
message.value = ''
try {
await restartMQTTForwarder(item.id)
message.value = 'MQTT 转发线程已重启'
await refreshStatus()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
async function toggleTopics(item: MQTTForwarder) {
expanded.value = { ...expanded.value, [item.id]: !expanded.value[item.id] }
if (expanded.value[item.id] && !topics.value[item.id]) {
await refreshTopics(item.id)
}
}
async function refreshTopics(forwarderId: number) {
const response = await getMQTTForwardTopics(forwarderId, topicPageSize, 0)
resetTopicEdits(forwarderId, response.items)
}
async function createTopic(forwarderId: number) {
error.value = ''
message.value = ''
let payload: MQTTForwardTopicPayload
try {
payload = topicPayload(newTopics.value[forwarderId] ?? defaultTopic())
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
return
}
try {
await createMQTTForwardTopic(forwarderId, payload)
newTopics.value = { ...newTopics.value, [forwarderId]: defaultTopic() }
message.value = 'TOPIC 已新增'
await refreshTopics(forwarderId)
await refreshStatus()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
async function saveTopic(topic: MQTTForwardTopic) {
error.value = ''
message.value = ''
const edit = topicEdits.value[topic.id]
if (!edit) return
try {
await updateMQTTForwardTopic(topic.id, topicPayload(edit))
message.value = 'TOPIC 已保存'
await refreshTopics(topic.forwarder_id)
await refreshStatus()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
async function removeTopic(topic: MQTTForwardTopic) {
if (!window.confirm(`确定删除 TOPIC「${topic.topic}」吗?`)) return
error.value = ''
message.value = ''
try {
await deleteMQTTForwardTopic(topic.id)
message.value = 'TOPIC 已删除'
await refreshTopics(topic.forwarder_id)
await refreshStatus()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
function statusText(item: MQTTForwarder): string {
const status = statuses.value[item.id]
if (!item.enabled) return '已禁用'
if (!status) return '未运行'
return status.source_connected && status.target_connected ? '已连接' : '连接中/异常'
}
onMounted(() => {
refreshForwarders()
statusTimer = window.setInterval(refreshStatus, 5000)
})
onBeforeUnmount(() => {
if (statusTimer !== undefined) {
window.clearInterval(statusTimer)
}
})
</script>
<template>
<section class="mqtt-forward-page">
<div class="mqtt-hero panel">
<div>
<p class="eyebrow">MQTT Forward</p>
<h2>MQTT 转发管理</h2>
<p class="muted">统一管理源 Broker目标 Broker 和每个 TOPIC 的转发方向保存配置后后端会自动重启对应线程</p>
</div>
<div class="hero-stats">
<div>
<strong>{{ total }}</strong>
<span>线程配置</span>
</div>
<div>
<strong>{{ Object.keys(statuses).length }}</strong>
<span>运行中</span>
</div>
</div>
</div>
<div class="panel form-panel">
<div class="panel-heading compact">
<div>
<p class="eyebrow">Create</p>
<h2>新增转发线程</h2>
</div>
<label class="switch-card">
<input v-model="newForwarder.enabled" type="checkbox" />
<span>创建后启用</span>
</label>
</div>
<form class="forward-form" @submit.prevent="createForwarder">
<label class="field span-2">名称<input v-model="newForwarder.name" placeholder="例如:Meshtastic CN" /></label>
<fieldset class="broker-card source-card">
<legend> Broker</legend>
<label class="field span-2">Host<input v-model="newForwarder.source_host" /></label>
<label class="field small">Port<input v-model="newForwarder.source_port" /></label>
<label class="field">用户名<input v-model="newForwarder.source_username" /></label>
<label class="field">密码<input v-model="newForwarder.source_password" type="password" /></label>
<label class="field span-2">Client ID<input v-model="newForwarder.source_client_id" placeholder="留空自动生成" /></label>
<label class="switch-card"><input v-model="newForwarder.source_tls" type="checkbox" /> <span>TLS</span></label>
</fieldset>
<fieldset class="broker-card target-card">
<legend>目标 Broker</legend>
<label class="field span-2">Host<input v-model="newForwarder.target_host" /></label>
<label class="field small">Port<input v-model="newForwarder.target_port" /></label>
<label class="field">用户名<input v-model="newForwarder.target_username" /></label>
<label class="field">密码<input v-model="newForwarder.target_password" type="password" /></label>
<label class="field span-2">Client ID<input v-model="newForwarder.target_client_id" placeholder="留空自动生成" /></label>
<label class="switch-card"><input v-model="newForwarder.target_tls" type="checkbox" /> <span>TLS</span></label>
</fieldset>
<div class="form-actions">
<button class="admin-button" type="submit" :disabled="loading">新增转发线程</button>
</div>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="message" class="success">{{ message }}</p>
</div>
<div class="panel list-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Forwarders</p>
<h2>转发线程</h2>
</div>
<button class="admin-button ghost" @click="refreshForwarders()" :disabled="loading">刷新</button>
</div>
<div v-if="!forwarders.length" class="empty-state">暂无 MQTT 转发线程先在上方创建一个配置</div>
<article v-for="item in forwarders" :key="item.id" class="forwarder-card">
<header class="forwarder-title">
<div>
<h3>{{ item.name }}</h3>
<p class="endpoint-line">{{ item.source_host }}:{{ item.source_port }} {{ item.target_host }}:{{ item.target_port }}</p>
</div>
<span
class="status-pill"
:class="{
ok: item.enabled && statuses[item.id]?.source_connected && statuses[item.id]?.target_connected,
disabled: !item.enabled,
warn: item.enabled && (!statuses[item.id]?.source_connected || !statuses[item.id]?.target_connected),
}"
>
{{ statusText(item) }}
</span>
</header>
<div class="runtime-grid">
<div><span>源连接</span><strong>{{ statuses[item.id]?.source_connected ? '已连接' : '未连接' }}</strong></div>
<div><span>目标连接</span><strong>{{ statuses[item.id]?.target_connected ? '已连接' : '未连接' }}</strong></div>
<div><span>已转发</span><strong>{{ statuses[item.id]?.messages_forwarded ?? 0 }}</strong></div>
<div><span>已丢弃</span><strong>{{ statuses[item.id]?.messages_dropped ?? 0 }}</strong></div>
<div class="span-2"><span>启动时间</span><strong>{{ formatTime(statuses[item.id]?.started_at ?? null) }}</strong></div>
</div>
<p v-if="statuses[item.id]?.last_error" class="inline-error">{{ statuses[item.id]?.last_error }}</p>
<div v-if="edits[item.id]" class="edit-shell">
<div class="edit-section main-section">
<label class="field">名称<input v-model="edits[item.id].name" /></label>
<label class="switch-card"><input v-model="edits[item.id].enabled" type="checkbox" /> <span>启用线程</span></label>
</div>
<div class="edit-section source-card">
<h4> Broker</h4>
<label class="field span-2">Host<input v-model="edits[item.id].source_host" /></label>
<label class="field small">Port<input v-model="edits[item.id].source_port" /></label>
<label class="field">用户名<input v-model="edits[item.id].source_username" /></label>
<label class="field">密码<input v-model="edits[item.id].source_password" type="password" :placeholder="item.source_password_set ? '留空保持原密码' : ''" /></label>
<label class="field span-2">Client ID<input v-model="edits[item.id].source_client_id" /></label>
<label class="switch-card"><input v-model="edits[item.id].source_password_clear" type="checkbox" /> <span>清空源密码</span></label>
<label class="switch-card"><input v-model="edits[item.id].source_tls" type="checkbox" /> <span> TLS</span></label>
</div>
<div class="edit-section target-card">
<h4>目标 Broker</h4>
<label class="field span-2">Host<input v-model="edits[item.id].target_host" /></label>
<label class="field small">Port<input v-model="edits[item.id].target_port" /></label>
<label class="field">用户名<input v-model="edits[item.id].target_username" /></label>
<label class="field">密码<input v-model="edits[item.id].target_password" type="password" :placeholder="item.target_password_set ? '留空保持原密码' : ''" /></label>
<label class="field span-2">Client ID<input v-model="edits[item.id].target_client_id" /></label>
<label class="switch-card"><input v-model="edits[item.id].target_password_clear" type="checkbox" /> <span>清空目标密码</span></label>
<label class="switch-card"><input v-model="edits[item.id].target_tls" type="checkbox" /> <span>目标 TLS</span></label>
</div>
</div>
<div class="actions">
<button class="admin-button" @click="saveForwarder(item)" :disabled="loading">保存并重启</button>
<button class="admin-button ghost" @click="restartForwarder(item)">仅重启</button>
<button class="admin-button danger" @click="removeForwarder(item)" :disabled="loading">删除</button>
<button class="admin-button secondary" @click="toggleTopics(item)">{{ expanded[item.id] ? '收起 TOPICS' : '管理 TOPICS' }}</button>
</div>
<div v-if="expanded[item.id]" class="topics-box">
<div class="topics-heading">
<div>
<p class="eyebrow">Topics</p>
<h4>订阅规则</h4>
</div>
<span class="badge">{{ topics[item.id]?.length ?? 0 }} </span>
</div>
<form v-if="newTopics[item.id]" class="topic-row new-topic" @submit.prevent="createTopic(item.id)">
<input v-model="newTopics[item.id].topic" placeholder="msh/#" />
<label class="mini-check"><input v-model="newTopics[item.id].enabled" type="checkbox" /> 启用</label>
<select v-model="newTopics[item.id].direction">
<option value="source_to_target">单向 目标</option>
<option value="bidirectional">双向</option>
</select>
<input v-model="newTopics[item.id].source_prefix" placeholder="源前缀" />
<input v-model="newTopics[item.id].target_prefix" placeholder="目标前缀" />
<select v-model.number="newTopics[item.id].qos">
<option :value="0">QoS 0</option>
<option :value="1">QoS 1</option>
<option :value="2">QoS 2</option>
</select>
<label class="mini-check"><input v-model="newTopics[item.id].retain" type="checkbox" /> Retain</label>
<button class="admin-button" type="submit">新增</button>
</form>
<div v-for="topic in topics[item.id] ?? []" :key="topic.id" class="topic-row">
<input v-model="topicEdits[topic.id].topic" />
<label class="mini-check"><input v-model="topicEdits[topic.id].enabled" type="checkbox" /> 启用</label>
<select v-model="topicEdits[topic.id].direction">
<option value="source_to_target">单向 目标</option>
<option value="bidirectional">双向</option>
</select>
<input v-model="topicEdits[topic.id].source_prefix" placeholder="源前缀" />
<input v-model="topicEdits[topic.id].target_prefix" placeholder="目标前缀" />
<select v-model.number="topicEdits[topic.id].qos">
<option :value="0">QoS 0</option>
<option :value="1">QoS 1</option>
<option :value="2">QoS 2</option>
</select>
<label class="mini-check"><input v-model="topicEdits[topic.id].retain" type="checkbox" /> Retain</label>
<button class="admin-button ghost" @click="saveTopic(topic)">保存</button>
<button class="admin-button danger" @click="removeTopic(topic)">删除</button>
</div>
</div>
</article>
<div class="pagination">
<button class="admin-button ghost" :disabled="!canPrev || loading" @click="refreshForwarders(page - 1)">上一页</button>
<span> {{ page }} · {{ total }} </span>
<button class="admin-button ghost" :disabled="!canNext || loading" @click="refreshForwarders(page + 1)">下一页</button>
</div>
</div>
</section>
</template>
<style scoped>
.mqtt-forward-page {
width: min(1440px, 100%);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.mqtt-forward-page :deep(input),
.mqtt-forward-page :deep(select) {
width: 100%;
box-sizing: border-box;
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 9px 11px;
color: #0f172a;
font: inherit;
background: #fff;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.mqtt-forward-page :deep(input:focus),
.mqtt-forward-page :deep(select:focus) {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
}
.mqtt-hero,
.form-panel,
.list-panel {
padding: 18px;
}
.mqtt-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%);
}
.mqtt-hero h2 {
font-size: 24px;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(110px, 1fr));
gap: 0.75rem;
}
.hero-stats div {
border: 1px solid #dbeafe;
border-radius: 16px;
padding: 12px 16px;
text-align: center;
background: rgba(255, 255, 255, 0.78);
}
.hero-stats strong {
display: block;
color: #1d4ed8;
font-size: 24px;
}
.hero-stats span,
.endpoint-line,
.runtime-grid span {
color: #64748b;
font-size: 13px;
}
.panel-heading,
.forwarder-title,
.actions,
.pagination,
.topics-heading {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.panel-heading,
.forwarder-title,
.topics-heading {
justify-content: space-between;
}
.panel-heading.compact {
margin-bottom: 1rem;
}
.forward-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.field {
display: grid;
gap: 6px;
color: #334155;
font-size: 13px;
font-weight: 700;
}
.span-2 {
grid-column: span 2;
}
.broker-card,
.edit-section,
.forwarder-card,
.topics-box {
border: 1px solid #dbe4ef;
border-radius: 16px;
background: #fff;
}
.broker-card {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
padding: 1rem;
}
.broker-card legend {
padding: 0 8px;
color: #334155;
font-weight: 800;
}
.source-card {
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
}
.target-card {
background: linear-gradient(180deg, #f8fffb 0%, #fff 100%);
}
.form-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
}
.switch-card,
.mini-check {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #dbe4ef;
border-radius: 12px;
padding: 9px 11px;
color: #334155;
font-size: 13px;
font-weight: 700;
background: #f8fafc;
}
.switch-card input,
.mini-check input {
width: auto;
}
.forwarder-card {
padding: 1rem;
margin-top: 1rem;
box-shadow: inset 4px 0 0 #dbeafe;
}
.forwarder-title h3 {
color: #0f172a;
font-size: 18px;
}
.status-pill {
border-radius: 999px;
padding: 7px 12px;
color: #92400e;
background: #fffbeb;
}
.status-pill.ok {
color: #166534;
background: #dcfce7;
}
.status-pill.warn {
color: #92400e;
background: #fef3c7;
}
.status-pill.disabled {
color: #475569;
background: #e2e8f0;
}
.runtime-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin: 1rem 0;
}
.runtime-grid div {
border-radius: 12px;
padding: 10px 12px;
background: #f8fafc;
}
.runtime-grid strong {
display: block;
margin-top: 3px;
color: #0f172a;
}
.inline-error {
border: 1px solid #fecaca;
border-radius: 12px;
padding: 10px 12px;
color: #b91c1c;
background: #fef2f2;
word-break: break-word;
}
.edit-shell {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0;
}
.edit-section {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
padding: 1rem;
}
.edit-section h4 {
grid-column: 1 / -1;
margin: 0;
}
.main-section {
grid-column: 1 / -1;
grid-template-columns: minmax(240px, 1fr) auto;
align-items: end;
}
.actions {
justify-content: flex-end;
margin-top: 0.75rem;
}
.admin-button.ghost {
color: #1d4ed8;
border: 1px solid #bfdbfe;
background: #eff6ff;
}
.admin-button.secondary {
background: #475569;
}
.admin-button.danger {
background: #dc2626;
}
.topics-box {
margin-top: 1rem;
padding: 1rem;
background: #f8fafc;
}
.topic-row {
display: grid;
grid-template-columns: minmax(180px, 1.6fr) minmax(90px, 0.7fr) minmax(150px, 1fr) repeat(2, minmax(120px, 1fr)) minmax(90px, 0.7fr) minmax(90px, 0.7fr) auto auto;
gap: 0.5rem;
align-items: center;
border-top: 1px solid #e2e8f0;
padding-top: 0.75rem;
margin-top: 0.75rem;
}
.topic-row.new-topic {
border: 1px dashed #93c5fd;
border-radius: 14px;
padding: 0.75rem;
background: #eff6ff;
}
.empty-state {
border: 1px dashed #cbd5e1;
border-radius: 16px;
padding: 24px;
color: #64748b;
text-align: center;
background: #f8fafc;
}
.pagination {
justify-content: center;
margin-top: 1rem;
}
@media (max-width: 1100px) {
.forward-form,
.edit-shell {
grid-template-columns: 1fr;
}
.broker-card,
.edit-section,
.topic-row {
grid-template-columns: 1fr 1fr;
}
.span-2,
.main-section {
grid-column: auto;
}
}
@media (max-width: 700px) {
.mqtt-hero,
.panel-heading,
.forwarder-title {
align-items: stretch;
flex-direction: column;
}
.hero-stats,
.broker-card,
.edit-section,
.topic-row,
.main-section {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: auto;
}
}
</style>