feat(kafka): improve topic management and Kerberos auth
This commit is contained in:
18 files changed
+1096
-81
No files matched your search
@@ -40,6 +40,7 @@ import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDete
|
||||
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout";
|
||||
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
|
||||
import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapServers";
|
||||
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
|
||||
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
|
||||
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue";
|
||||
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases";
|
||||
@@ -390,14 +391,17 @@ const dialogStep = ref<DialogStep>("select");
|
||||
const dbPickerView = ref<DbPickerView>("icon");
|
||||
const dbSearchQuery = ref("");
|
||||
const configTab = ref<ConfigTab>("connection");
|
||||
type MqAuthKind = MqAuth["kind"];
|
||||
const MQ_KAFKA_SECURITY_PROTOCOL_AUTO = "__auto";
|
||||
const mqAdminUrl = ref("http://127.0.0.1:8080");
|
||||
const mqSystemKind = ref<MqSystemKind>("pulsar");
|
||||
const mqKafkaBootstrapServers = ref("127.0.0.1:9092");
|
||||
const mqKafkaSecurityProtocol = ref(MQ_KAFKA_SECURITY_PROTOCOL_AUTO);
|
||||
const mqKafkaSaslMechanism = ref("PLAIN");
|
||||
const mqAuthKind = ref<MqAuthKind>("none");
|
||||
const mqKafkaKerberosPrincipal = ref("");
|
||||
const mqKafkaKerberosKeytabPath = ref("");
|
||||
const mqKafkaKerberosServiceName = ref("kafka");
|
||||
const mqKafkaKrb5ConfPath = ref("");
|
||||
const mqAuthKind = ref<MqUiAuthKind>("none");
|
||||
const mqToken = ref("");
|
||||
const mqBasicUsername = ref("");
|
||||
const mqBasicPassword = ref("");
|
||||
@@ -693,18 +697,50 @@ function mqExtraString(extra: Record<string, unknown>, key: string): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function mqExtraProperties(extra: Record<string, unknown>): Record<string, unknown> {
|
||||
const value = extra.properties;
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function mqExtraPropertyString(extra: Record<string, unknown>, key: string): string {
|
||||
const value = mqExtraProperties(extra)[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function jaasStringValue(value: string): string {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
}
|
||||
|
||||
function parseJaasStringProperty(value: string, key: string): string {
|
||||
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = value.match(new RegExp(`${escapedKey}\\s*=\\s*"((?:\\\\.|[^"\\\\])*)"`, "i"));
|
||||
if (!match) return "";
|
||||
return match[1].replace(/\\(["\\])/g, "$1");
|
||||
}
|
||||
|
||||
function resetMqFields(config?: Partial<MqAdminConfig>) {
|
||||
const systemKind = config?.systemKind === "kafka" ? "kafka" : "pulsar";
|
||||
const extra = mqExtraRecord(config);
|
||||
const properties = mqExtraProperties(extra);
|
||||
const jaasConfig = mqExtraPropertyString(extra, "sasl.jaas.config");
|
||||
mqSystemKind.value = systemKind;
|
||||
mqAdminUrl.value = config?.adminUrl?.trim() || (systemKind === "kafka" ? "" : "http://127.0.0.1:8080");
|
||||
mqKafkaBootstrapServers.value = mqExtraString(extra, "bootstrapServers") || "127.0.0.1:9092";
|
||||
mqKafkaSecurityProtocol.value = mqExtraString(extra, "securityProtocol") || MQ_KAFKA_SECURITY_PROTOCOL_AUTO;
|
||||
mqKafkaSaslMechanism.value = mqExtraString(extra, "saslMechanism") || "PLAIN";
|
||||
mqKafkaKerberosPrincipal.value = parseJaasStringProperty(jaasConfig, "principal");
|
||||
mqKafkaKerberosKeytabPath.value = parseJaasStringProperty(jaasConfig, "keyTab");
|
||||
mqKafkaKerberosServiceName.value = typeof properties["sasl.kerberos.service.name"] === "string" ? properties["sasl.kerberos.service.name"] : "kafka";
|
||||
mqKafkaKrb5ConfPath.value = typeof properties["java.security.krb5.conf"] === "string" ? properties["java.security.krb5.conf"] : "";
|
||||
mqTlsSkipVerify.value = !!config?.tlsSkipVerify;
|
||||
mqPinnedVersion.value = pinnedVersionToSelection(config?.pinnedVersion);
|
||||
const auth = (config?.auth || { kind: "none" }) as MqAuth;
|
||||
mqAuthKind.value = systemKind === "kafka" && auth.kind !== "basic" ? "none" : auth.kind || "none";
|
||||
mqAuthKind.value = detectMqUiAuthKind({
|
||||
systemKind,
|
||||
authKind: auth.kind,
|
||||
saslMechanism: mqKafkaSaslMechanism.value,
|
||||
jaasConfig,
|
||||
});
|
||||
mqToken.value = auth.token || "";
|
||||
mqBasicUsername.value = auth.username || "";
|
||||
mqBasicPassword.value = auth.password || "";
|
||||
@@ -741,12 +777,18 @@ function hydrateMqFields(value: unknown) {
|
||||
watch(mqSystemKind, (kind) => {
|
||||
if (kind === "kafka") {
|
||||
if (!mqKafkaBootstrapServers.value.trim()) mqKafkaBootstrapServers.value = "127.0.0.1:9092";
|
||||
if (!["none", "basic"].includes(mqAuthKind.value)) mqAuthKind.value = "none";
|
||||
if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none";
|
||||
return;
|
||||
}
|
||||
if (!mqAdminUrl.value.trim()) mqAdminUrl.value = "http://127.0.0.1:8080";
|
||||
});
|
||||
|
||||
watch(mqAuthKind, (kind) => {
|
||||
if (mqSystemKind.value === "kafka" && kind === "basic" && mqKafkaSaslMechanism.value.toUpperCase() === "GSSAPI") {
|
||||
mqKafkaSaslMechanism.value = "PLAIN";
|
||||
}
|
||||
});
|
||||
|
||||
function resetNacosFields(config?: Partial<NacosAdminConfig>) {
|
||||
nacosServerAddr.value = config?.serverAddr?.trim() || NACOS_DEFAULT_CONSOLE_URL;
|
||||
nacosNamespace.value = config?.namespace || "";
|
||||
@@ -845,6 +887,12 @@ function buildMqAuth(): MqAuth {
|
||||
}
|
||||
}
|
||||
|
||||
function buildKafkaKerberosJaasConfig(): string {
|
||||
const principal = requireMqField(mqKafkaKerberosPrincipal.value, t("connection.kafkaKerberosPrincipalRequired"));
|
||||
const keytab = requireMqField(mqKafkaKerberosKeytabPath.value, t("connection.kafkaKerberosKeytabRequired"));
|
||||
return `com.sun.security.auth.module.Krb5LoginModule required useKeyTab=true storeKey=true keyTab="${jaasStringValue(keytab)}" principal="${jaasStringValue(principal)}";`;
|
||||
}
|
||||
|
||||
function buildMqTokenSigning() {
|
||||
if (mqTokenSigningMode.value === "none") return undefined;
|
||||
return {
|
||||
@@ -857,11 +905,21 @@ function buildMqAdminConfig(): MqAdminConfig {
|
||||
const systemKind = mqSystemKind.value;
|
||||
if (systemKind === "kafka") {
|
||||
const bootstrapServers = normalizeKafkaBootstrapServers(mqKafkaBootstrapServers.value);
|
||||
const extra: Record<string, string> = { bootstrapServers };
|
||||
const extra: Record<string, unknown> = { bootstrapServers };
|
||||
const securityProtocol = mqKafkaSecurityProtocol.value === MQ_KAFKA_SECURITY_PROTOCOL_AUTO ? "" : mqKafkaSecurityProtocol.value.trim();
|
||||
const saslMechanism = mqKafkaSaslMechanism.value.trim();
|
||||
const saslMechanism = mqAuthKind.value === "kerberos" ? "GSSAPI" : mqKafkaSaslMechanism.value.trim();
|
||||
const properties: Record<string, string> = {};
|
||||
if (securityProtocol) extra.securityProtocol = securityProtocol;
|
||||
if (mqAuthKind.value === "basic" && saslMechanism) extra.saslMechanism = saslMechanism;
|
||||
if (mqAuthKind.value === "kerberos") {
|
||||
extra.saslMechanism = "GSSAPI";
|
||||
properties["sasl.jaas.config"] = buildKafkaKerberosJaasConfig();
|
||||
properties["sasl.kerberos.service.name"] = mqKafkaKerberosServiceName.value.trim() || "kafka";
|
||||
if (mqKafkaKrb5ConfPath.value.trim()) {
|
||||
properties["java.security.krb5.conf"] = mqKafkaKrb5ConfPath.value.trim();
|
||||
}
|
||||
}
|
||||
if (Object.keys(properties).length) extra.properties = properties;
|
||||
return {
|
||||
systemKind: mqSystemKind.value,
|
||||
adminUrl: "",
|
||||
@@ -3908,6 +3966,7 @@ function openExternalUrl(url: string) {
|
||||
<Button size="sm" :variant="mqAuthKind === 'none' ? 'default' : 'outline'" @click="mqAuthKind = 'none'">None</Button>
|
||||
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'token' ? 'default' : 'outline'" @click="mqAuthKind = 'token'">Token</Button>
|
||||
<Button size="sm" :variant="mqAuthKind === 'basic' ? 'default' : 'outline'" @click="mqAuthKind = 'basic'">Basic</Button>
|
||||
<Button v-if="mqSystemKind === 'kafka'" size="sm" :variant="mqAuthKind === 'kerberos' ? 'default' : 'outline'" @click="mqAuthKind = 'kerberos'">Kerberos</Button>
|
||||
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'apiKey' ? 'default' : 'outline'" @click="mqAuthKind = 'apiKey'">API Key</Button>
|
||||
<Button v-if="mqSystemKind !== 'kafka'" size="sm" :variant="mqAuthKind === 'oauth2' ? 'default' : 'outline'" @click="mqAuthKind = 'oauth2'">OAuth2</Button>
|
||||
</div>
|
||||
@@ -3941,6 +4000,31 @@ function openExternalUrl(url: string) {
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="mqSystemKind === 'kafka' && mqAuthKind === 'kerberos'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.kafkaKerberosPrincipal") }}</Label>
|
||||
<Input v-model="mqKafkaKerberosPrincipal" class="col-span-3" placeholder="user@EXAMPLE.COM" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.kafkaKerberosKeytab") }}</Label>
|
||||
<Input v-model="mqKafkaKerberosKeytabPath" class="col-span-3" :placeholder="t('connection.kafkaKerberosKeytabPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.kafkaKerberosServiceName") }}</Label>
|
||||
<Input v-model="mqKafkaKerberosServiceName" class="col-span-3" placeholder="kafka" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.kafkaKerberosKrb5Conf") }}</Label>
|
||||
<Input v-model="mqKafkaKrb5ConfPath" class="col-span-3" :placeholder="t('connection.kafkaKerberosKrb5ConfPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<div></div>
|
||||
<div class="col-span-3 space-y-1 text-xs leading-5 text-muted-foreground">
|
||||
<p>{{ t("connection.kafkaKerberosPathHint") }}</p>
|
||||
<p>{{ t("connection.kafkaKerberosAuthHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="mqAuthKind === 'apiKey'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">Header</Label>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
@@ -40,6 +42,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { toast } = useToast();
|
||||
@@ -414,10 +417,23 @@ function tabColorStyle(tab: QueryTab) {
|
||||
}
|
||||
|
||||
function tabIconClass(tab: QueryTab) {
|
||||
if (tab.mode === "mq") return "";
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
function tabDatabaseIconType(tab: QueryTab) {
|
||||
const connection = connectionStore.getConfig(tab.connectionId);
|
||||
if (!connection) return "mq";
|
||||
if (connection.db_type === "mq") {
|
||||
const externalConfig = connection.external_config as { systemKind?: unknown } | undefined;
|
||||
const systemKind = typeof externalConfig?.systemKind === "string" ? externalConfig.systemKind : "";
|
||||
if (connection.driver_profile === "kafka" || systemKind === "kafka") return "kafka";
|
||||
if (connection.driver_profile === "pulsar" || systemKind === "pulsar") return "pulsar";
|
||||
}
|
||||
return connection.driver_profile || connection.db_type;
|
||||
}
|
||||
|
||||
const showRegularTabScrollbar = computed(() => hasTabOverflow.value);
|
||||
const showFixedTabScrollbar = computed(() => hasFixedTabOverflow.value);
|
||||
const showRegularTabOverflowControls = computed(() => regularTabs.value.length > 0 && hasTabOverflow.value);
|
||||
@@ -561,6 +577,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||||
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
|
||||
@@ -682,7 +699,8 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
@contextmenu="onContextMenu"
|
||||
@keydown="onOverflowItemKeydown($event, tab.id, 'regular')"
|
||||
>
|
||||
<component :is="tabMenuIcon(tab)" :class="['h-3.5 w-3.5 shrink-0', tabIconClass(tab)]" />
|
||||
<DatabaseIcon v-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<component :is="tabMenuIcon(tab)" v-else :class="['h-3.5 w-3.5 shrink-0', tabIconClass(tab)]" />
|
||||
<span class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||||
@@ -741,6 +759,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis'" class="h-3.5 w-3.5" />
|
||||
<DatabaseIcon v-else-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
|
||||
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
|
||||
@@ -810,7 +829,8 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
|
||||
@contextmenu="onContextMenu"
|
||||
@keydown="onOverflowItemKeydown($event, tab.id, 'fixed')"
|
||||
>
|
||||
<component :is="tabMenuIcon(tab)" :class="['h-3.5 w-3.5 shrink-0', tabIconClass(tab)]" />
|
||||
<DatabaseIcon v-if="tab.mode === 'mq'" :db-type="tabDatabaseIconType(tab)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<component :is="tabMenuIcon(tab)" v-else :class="['h-3.5 w-3.5 shrink-0', tabIconClass(tab)]" />
|
||||
<span class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
|
||||
|
||||
@@ -7,8 +7,8 @@ import { LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||
import VChart from "vue-echarts";
|
||||
import { Activity, AlertTriangle, BarChart3, Boxes, CheckCircle2, Database, Download, Gauge, Hash, HardDrive, Layers3, Loader2, Package, RadioTower, RefreshCw, Send, ShieldCheck, Table2, Upload, Users } from "@lucide/vue";
|
||||
import type { TopicRef, TopicInfo, TopicStats, BacklogStats } from "@/types/mq";
|
||||
import { mqGetTopicStats, mqGetBacklog } from "@/lib/backend/api";
|
||||
import type { TopicRef, TopicInfo, TopicStats, BacklogStats, PeekedMessage } from "@/types/mq";
|
||||
import { mqGetTopicStats, mqGetBacklog, mqPeekMessages } from "@/lib/backend/api";
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]);
|
||||
|
||||
@@ -62,6 +62,10 @@ const error = ref<string>();
|
||||
const autoRefresh = ref(true);
|
||||
const refreshInterval = ref(5); // seconds
|
||||
const selectedPartitionName = ref<string>();
|
||||
const kafkaMessageSql = ref("");
|
||||
const kafkaMessageLoading = ref(false);
|
||||
const kafkaMessageError = ref<string>();
|
||||
const kafkaMessages = ref<PeekedMessage[]>([]);
|
||||
|
||||
let refreshTimer: number | undefined;
|
||||
const history = ref<MetricPoint[]>([]);
|
||||
@@ -194,6 +198,61 @@ function refreshNow() {
|
||||
void loadStats();
|
||||
}
|
||||
|
||||
function defaultKafkaMessageSql(): string {
|
||||
const topic = props.topic?.shortName;
|
||||
return topic ? `SELECT * FROM "${topic}" PARTITION 0 OFFSET 0 LIMIT 20` : "";
|
||||
}
|
||||
|
||||
function parseKafkaMessageSql(sql: string): { topic: string; partition: number; offset: number; limit: number } {
|
||||
const match = sql.trim().match(/^\s*select\s+\*\s+from\s+(?:"([^"]+)"|`([^`]+)`|'([^']+)'|([^\s;]+))(?:\s+partition\s+(\d+))?(?:\s+offset\s+(\d+))?(?:\s+limit\s+(\d+))?\s*;?\s*$/i);
|
||||
if (!match) {
|
||||
throw new Error('仅支持 SELECT * FROM "topic" [PARTITION n] [OFFSET n] [LIMIT n]');
|
||||
}
|
||||
const topic = match[1] || match[2] || match[3] || match[4] || "";
|
||||
const partition = Math.max(0, Number(match[5] ?? 0));
|
||||
const offset = Math.max(0, Number(match[6] ?? 0));
|
||||
const limit = Math.max(1, Math.min(100, Number(match[7] ?? 20)));
|
||||
return { topic, partition, offset, limit };
|
||||
}
|
||||
|
||||
async function runKafkaMessageSql() {
|
||||
if (!props.tenant || !props.namespace) return;
|
||||
kafkaMessageLoading.value = true;
|
||||
kafkaMessageError.value = undefined;
|
||||
try {
|
||||
const parsed = parseKafkaMessageSql(kafkaMessageSql.value);
|
||||
const selected = props.topic && parsed.topic === props.topic.shortName ? props.topic : undefined;
|
||||
kafkaMessages.value = await mqPeekMessages(
|
||||
props.connectionId,
|
||||
{
|
||||
tenant: props.tenant,
|
||||
namespace: props.namespace,
|
||||
topic: parsed.topic,
|
||||
persistent: selected?.persistent ?? true,
|
||||
partitioned: selected?.partitioned,
|
||||
},
|
||||
"__dbx_kafka_monitor__",
|
||||
parsed.limit,
|
||||
{ partition: parsed.partition, offset: parsed.offset },
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
kafkaMessageError.value = formatError(e);
|
||||
} finally {
|
||||
kafkaMessageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function kafkaMessagePayload(message: PeekedMessage): string {
|
||||
return message.payloadText ?? message.payloadBase64;
|
||||
}
|
||||
|
||||
function formatKafkaMessageTimestamp(value?: string): string {
|
||||
if (!value) return "-";
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return value;
|
||||
return new Date(numeric).toLocaleString();
|
||||
}
|
||||
|
||||
function appendHistoryPoint(statsData: TopicStats, backlogData?: BacklogStats) {
|
||||
const point: MetricPoint = {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
@@ -349,6 +408,9 @@ watch(
|
||||
() => {
|
||||
history.value = [];
|
||||
selectedPartitionName.value = undefined;
|
||||
kafkaMessageSql.value = defaultKafkaMessageSql();
|
||||
kafkaMessageError.value = undefined;
|
||||
kafkaMessages.value = [];
|
||||
void loadStats();
|
||||
startAutoRefresh();
|
||||
},
|
||||
@@ -534,6 +596,38 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div v-else class="empty-state compact">当前 Kafka 响应未返回分区指标</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<div class="section-title-row">
|
||||
<h4>Kafka 消息查询</h4>
|
||||
<button type="button" class="btn-sm" :disabled="kafkaMessageLoading || !kafkaMessageSql.trim()" @click="runKafkaMessageSql">
|
||||
<Loader2 v-if="kafkaMessageLoading" class="btn-icon spinning" :size="14" />
|
||||
<span>{{ kafkaMessageLoading ? "查询中..." : "查询消息" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea v-model="kafkaMessageSql" class="kafka-sql-input" rows="2" spellcheck="false" />
|
||||
<div class="query-hint">支持:SELECT * FROM "topic" PARTITION 0 OFFSET 0 LIMIT 20,单次最多返回 100 条。</div>
|
||||
<div v-if="kafkaMessageError" class="panel-error inline-error">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>{{ kafkaMessageError }}</span>
|
||||
</div>
|
||||
<div v-else-if="kafkaMessageLoading" class="empty-state compact">消息加载中...</div>
|
||||
<div v-else-if="!kafkaMessages.length" class="empty-state compact">暂无消息</div>
|
||||
<div v-else class="kafka-message-list">
|
||||
<article v-for="message in kafkaMessages" :key="message.messageId || message.position" class="kafka-message-row">
|
||||
<div class="kafka-message-meta">
|
||||
<span>#{{ message.position }}</span>
|
||||
<span>offset {{ message.messageId || "-" }}</span>
|
||||
<span v-if="message.key">key {{ message.key }}</span>
|
||||
<span>{{ formatKafkaMessageTimestamp(message.publishTime) }}</span>
|
||||
</div>
|
||||
<pre class="kafka-message-payload">{{ kafkaMessagePayload(message) }}</pre>
|
||||
<div v-if="Object.keys(message.headers || {}).length" class="kafka-message-headers">
|
||||
<span v-for="(value, key) in message.headers" :key="key">{{ key }}: {{ value }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="stats" class="stats-container">
|
||||
@@ -1059,6 +1153,99 @@ onUnmounted(() => {
|
||||
box-shadow: 0 0 0 4px var(--monitor-accent-soft);
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title-row h4 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.kafka-sql-input {
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--monitor-border);
|
||||
border-radius: 8px;
|
||||
background: var(--monitor-surface);
|
||||
color: var(--monitor-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.kafka-sql-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--monitor-accent);
|
||||
box-shadow: 0 0 0 3px var(--monitor-accent-soft);
|
||||
}
|
||||
|
||||
.query-hint {
|
||||
margin-top: 6px;
|
||||
color: var(--monitor-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
justify-content: flex-start;
|
||||
margin-top: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--monitor-danger) 22%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.kafka-message-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.kafka-message-row {
|
||||
border: 1px solid var(--monitor-border);
|
||||
border-radius: 8px;
|
||||
background: var(--monitor-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kafka-message-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--monitor-border);
|
||||
color: var(--monitor-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kafka-message-payload {
|
||||
max-height: 220px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
color: var(--monitor-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kafka-message-headers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--monitor-border);
|
||||
color: var(--monitor-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
|
||||
@@ -129,7 +129,7 @@ function handleNamespaceRolesSelected(namespace: string) {
|
||||
function handleTopicSelected(topic: TopicInfo) {
|
||||
selectedTopic.value = topic;
|
||||
selectedSubscriptionName.value = undefined;
|
||||
activeTab.value = canManageSubscriptions.value ? "subscriptions" : "monitoring";
|
||||
activeTab.value = isKafkaCluster.value ? "monitoring" : canManageSubscriptions.value ? "subscriptions" : "monitoring";
|
||||
}
|
||||
|
||||
function handleSubscriptionSelected(subscription: string) {
|
||||
|
||||
@@ -59,6 +59,7 @@ const selectedTopicRef = computed<TopicRef | null>(() => {
|
||||
});
|
||||
|
||||
const canBrowseMessages = computed(() => props.isKafkaCluster === true && props.supportsPeekMessages !== false);
|
||||
const topicListId = computed(() => `mq-topic-options-${props.connectionId}`);
|
||||
|
||||
function clearSuccessLater() {
|
||||
if (successTimer) clearTimeout(successTimer);
|
||||
@@ -253,18 +254,17 @@ watch(
|
||||
<div class="form-group">
|
||||
<label>目标主题 <span class="required">*</span></label>
|
||||
<div class="topic-select-row">
|
||||
<select v-model="topicName" :disabled="readOnly || topicsLoading" class="topic-select">
|
||||
<option value="" disabled>{{ topicsLoading ? "加载中..." : "选择主题..." }}</option>
|
||||
<option v-for="t in topicOptions" :key="t.value" :value="t.value">
|
||||
{{ t.label }}<template v-if="t.partitions != null"> ({{ t.partitions }} 分区)</template>
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="topicName" :list="topicListId" :disabled="readOnly || topicsLoading" class="topic-input" :placeholder="topicsLoading ? '加载中...' : '输入或搜索主题...'" autocomplete="off" />
|
||||
<datalist :id="topicListId">
|
||||
<option v-for="t in topicOptions" :key="t.value" :value="t.value" :label="t.partitions != null ? `${t.label} (${t.partitions} 分区)` : t.label" />
|
||||
</datalist>
|
||||
<button @click="loadTopics" :disabled="topicsLoading" class="btn-icon" title="刷新主题列表">
|
||||
<span v-if="topicsLoading" class="spin">⟳</span>
|
||||
<span v-else>⟳</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!availableTopics.length && !topicsLoading" class="form-hint">暂无可用主题</div>
|
||||
<div v-else class="form-hint">可输入关键词搜索,也可以直接粘贴 topic 名称。</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息键 -->
|
||||
@@ -462,7 +462,7 @@ watch(
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topic-select {
|
||||
.topic-input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -470,11 +470,10 @@ watch(
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topic-select:focus {
|
||||
.topic-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts } from "@/types/mq";
|
||||
import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions } from "@/lib/backend/api";
|
||||
import { formatError } from "@/lib/backend/errorUtils";
|
||||
@@ -18,13 +19,17 @@ const emit = defineEmits<{
|
||||
topicSelected: [topic: TopicInfo];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const topics = ref<TopicInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const dialogError = ref<string>();
|
||||
const showCreateDialog = ref(false);
|
||||
const showPartitionsDialog = ref(false);
|
||||
const selectedTopic = ref<TopicInfo>();
|
||||
const editingTopic = ref<TopicInfo>();
|
||||
const topicSearch = ref("");
|
||||
|
||||
const formData = ref({
|
||||
topicName: "",
|
||||
@@ -36,15 +41,23 @@ const formData = ref({
|
||||
const newPartitions = ref(4);
|
||||
|
||||
const includeNonPersistent = ref(false);
|
||||
const readOnlyMessage = "当前连接为只读模式,不能执行写操作";
|
||||
|
||||
const filteredTopics = computed(() => {
|
||||
return topics.value;
|
||||
const query = topicSearch.value.trim().toLowerCase();
|
||||
if (!query) return topics.value;
|
||||
return topics.value.filter((topic) => {
|
||||
return topic.name.toLowerCase().includes(query) || topic.shortName.toLowerCase().includes(query);
|
||||
});
|
||||
});
|
||||
const editingCurrentPartitions = computed(() => editingTopic.value?.partitions ?? 0);
|
||||
const canSubmitPartitionUpdate = computed(() => {
|
||||
const current = editingCurrentPartitions.value;
|
||||
return !props.readOnly && current > 0 && Number.isFinite(newPartitions.value) && newPartitions.value > current;
|
||||
});
|
||||
|
||||
function guardWritable() {
|
||||
if (props.readOnly) {
|
||||
error.value = readOnlyMessage;
|
||||
error.value = t("mqTopics.readOnly");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -75,6 +88,7 @@ async function loadTopics() {
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!guardWritable()) return;
|
||||
dialogError.value = undefined;
|
||||
formData.value = {
|
||||
topicName: "",
|
||||
persistent: true,
|
||||
@@ -86,8 +100,9 @@ function openCreateDialog() {
|
||||
|
||||
function openPartitionsDialog(topic: TopicInfo) {
|
||||
if (!guardWritable()) return;
|
||||
dialogError.value = undefined;
|
||||
if (!topic.partitions || topic.partitions < 1) {
|
||||
error.value = "当前分区数未知,无法安全调整分区";
|
||||
error.value = t("mqTopics.currentPartitionsUnknown");
|
||||
return;
|
||||
}
|
||||
editingTopic.value = topic;
|
||||
@@ -98,7 +113,7 @@ function openPartitionsDialog(topic: TopicInfo) {
|
||||
async function handleCreate() {
|
||||
if (!guardWritable()) return;
|
||||
if (!formData.value.topicName.trim() || !props.tenant || !props.namespace) {
|
||||
error.value = "Topic name is required";
|
||||
dialogError.value = t("mqTopics.topicNameRequired");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
@@ -113,9 +128,10 @@ async function handleCreate() {
|
||||
const partitions = props.supportsPartitionedTopics !== false && formData.value.partitioned ? formData.value.partitions : undefined;
|
||||
await mqCreateTopic(props.connectionId, topicRef, partitions);
|
||||
showCreateDialog.value = false;
|
||||
dialogError.value = undefined;
|
||||
await loadTopics();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
dialogError.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -123,7 +139,7 @@ async function handleCreate() {
|
||||
|
||||
async function handleDelete(topic: TopicInfo) {
|
||||
if (!guardWritable()) return;
|
||||
if (!confirm(`确定要删除主题 "${topic.shortName}" 吗?此操作不可逆。`)) return;
|
||||
if (!confirm(t("mqTopics.confirmDelete", { name: topic.shortName }))) return;
|
||||
if (!props.tenant || !props.namespace) return;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
@@ -151,11 +167,11 @@ async function handleUpdatePartitions() {
|
||||
if (!editingTopic.value || !props.tenant || !props.namespace) return;
|
||||
const currentPartitions = editingTopic.value.partitions;
|
||||
if (!currentPartitions || currentPartitions < 1) {
|
||||
error.value = "当前分区数未知,无法安全调整分区";
|
||||
dialogError.value = t("mqTopics.currentPartitionsUnknown");
|
||||
return;
|
||||
}
|
||||
if (newPartitions.value <= currentPartitions) {
|
||||
error.value = "新分区数必须大于当前分区数";
|
||||
dialogError.value = t("mqTopics.partitionMustIncrease");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
@@ -169,9 +185,10 @@ async function handleUpdatePartitions() {
|
||||
};
|
||||
await mqUpdatePartitions(props.connectionId, topicRef, newPartitions.value);
|
||||
showPartitionsDialog.value = false;
|
||||
dialogError.value = undefined;
|
||||
await loadTopics();
|
||||
} catch (e: unknown) {
|
||||
error.value = formatError(e);
|
||||
dialogError.value = formatError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -182,6 +199,14 @@ function selectTopic(topic: TopicInfo) {
|
||||
emit("topicSelected", topic);
|
||||
}
|
||||
|
||||
function normalizePartitionInput() {
|
||||
const min = editingCurrentPartitions.value + 1;
|
||||
if (!showPartitionsDialog.value || min <= 1) return;
|
||||
if (!Number.isFinite(Number(newPartitions.value)) || Number(newPartitions.value) < min) {
|
||||
newPartitions.value = min;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.tenant, props.namespace],
|
||||
() => {
|
||||
@@ -194,42 +219,52 @@ watch(
|
||||
watch(includeNonPersistent, () => {
|
||||
loadTopics();
|
||||
});
|
||||
|
||||
watch(newPartitions, () => {
|
||||
if (dialogError.value === t("mqTopics.partitionMustIncrease") && canSubmitPartitionUpdate.value) {
|
||||
dialogError.value = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="topics-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h3>主题管理</h3>
|
||||
<h3>{{ t("mqTopics.title") }}</h3>
|
||||
<input v-model="topicSearch" type="search" class="topic-search" :placeholder="t('mqTopics.searchPlaceholder')" :disabled="loading && !topics.length" />
|
||||
<span v-if="topics.length" class="topic-count">{{ filteredTopics.length }} / {{ topics.length }}</span>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="includeNonPersistent" />
|
||||
包含非持久化主题
|
||||
{{ t("mqTopics.includeNonPersistent") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="loadTopics" :disabled="loading || !tenant || !namespace" class="btn-secondary">
|
||||
{{ loading ? "刷新中..." : "刷新" }}
|
||||
{{ loading ? t("mqTopics.refreshing") : t("mqTopics.refresh") }}
|
||||
</button>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant || !namespace" class="btn-primary">+ 创建主题</button>
|
||||
<button @click="openCreateDialog" :disabled="loading || readOnly || !tenant || !namespace" class="btn-primary">+ {{ t("mqTopics.createTopic") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!tenant || !namespace" class="panel-placeholder">请先选择租户和命名空间</div>
|
||||
<div v-if="!tenant || !namespace" class="panel-placeholder">{{ t("mqTopics.selectTenantNamespace") }}</div>
|
||||
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="loading && !topics.length" class="panel-loading">加载中...</div>
|
||||
<div v-else-if="loading && !topics.length" class="panel-loading">{{ t("mqTopics.loading") }}</div>
|
||||
|
||||
<div v-else-if="!topics.length" class="panel-placeholder">该命名空间下暂无主题</div>
|
||||
<div v-else-if="!topics.length" class="panel-placeholder">{{ t("mqTopics.noTopics") }}</div>
|
||||
|
||||
<div v-else-if="!filteredTopics.length" class="panel-placeholder">{{ t("mqTopics.noMatches") }}</div>
|
||||
|
||||
<div v-else class="topics-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>分区</th>
|
||||
<th>操作</th>
|
||||
<th>{{ t("mqTopics.name") }}</th>
|
||||
<th>{{ t("mqTopics.type") }}</th>
|
||||
<th>{{ t("mqTopics.partitions") }}</th>
|
||||
<th>{{ t("mqTopics.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -237,21 +272,23 @@ watch(includeNonPersistent, () => {
|
||||
<td class="topic-name">
|
||||
<div class="topic-name-cell">
|
||||
<span>{{ topic.shortName }}</span>
|
||||
<span v-if="!topic.persistent" class="badge badge-warning">非持久化</span>
|
||||
<span v-if="!topic.persistent" class="badge badge-warning">{{ t("mqTopics.nonPersistent") }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="topic.partitioned ? 'badge-info' : 'badge-default'">
|
||||
{{ topic.partitioned ? "分区主题" : "普通主题" }}
|
||||
{{ topic.partitioned ? t("mqTopics.partitionedTopic") : t("mqTopics.normalTopic") }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="topic.partitioned">{{ topic.partitions ? `${topic.partitions} 个分区` : "分区数未知" }}</span>
|
||||
<span v-if="topic.partitioned">{{ topic.partitions ? t("mqTopics.partitionCount", { count: topic.partitions }) : t("mqTopics.partitionsUnknown") }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button v-if="topic.partitioned && supportsPartitionedTopics !== false" @click.stop="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">调整分区</button>
|
||||
<button @click.stop="handleDelete(topic)" :disabled="readOnly" class="btn-sm btn-danger">删除</button>
|
||||
<button v-if="topic.partitioned && supportsPartitionedTopics !== false" @click.stop="openPartitionsDialog(topic)" :disabled="readOnly || !topic.partitions" class="btn-sm">
|
||||
{{ t("mqTopics.adjustPartitions") }}
|
||||
</button>
|
||||
<button @click.stop="handleDelete(topic)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqTopics.delete") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -262,41 +299,41 @@ watch(includeNonPersistent, () => {
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>创建主题</h3>
|
||||
<h3>{{ t("mqTopics.createTopic") }}</h3>
|
||||
<button @click="showCreateDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div v-if="!isKafkaCluster" class="form-group">
|
||||
<label>租户 / 命名空间</label>
|
||||
<label>{{ t("mqTopics.tenantNamespace") }}</label>
|
||||
<input type="text" :value="`${tenant} / ${namespace}`" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>主题名称*</label>
|
||||
<input v-model="formData.topicName" type="text" placeholder="例如: my-topic" :disabled="readOnly" />
|
||||
<label>{{ t("mqTopics.topicName") }}*</label>
|
||||
<input v-model="formData.topicName" type="text" :placeholder="t('mqTopics.topicNamePlaceholder')" :disabled="readOnly" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="formData.persistent" :disabled="readOnly" />
|
||||
持久化主题(推荐)
|
||||
{{ t("mqTopics.persistentRecommended") }}
|
||||
</label>
|
||||
<div class="form-hint">持久化主题会将消息保存到磁盘,非持久化主题仅保存在内存中</div>
|
||||
<div class="form-hint">{{ t("mqTopics.persistentHint") }}</div>
|
||||
</div>
|
||||
<div v-if="supportsPartitionedTopics !== false" class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="formData.partitioned" :disabled="readOnly" />
|
||||
启用分区
|
||||
{{ t("mqTopics.enablePartitions") }}
|
||||
</label>
|
||||
<div v-if="formData.partitioned" class="form-subgroup">
|
||||
<label>分区数量*</label>
|
||||
<label>{{ t("mqTopics.partitionQuantity") }}*</label>
|
||||
<input v-model.number="formData.partitions" type="number" min="1" max="256" :disabled="readOnly" />
|
||||
<div class="form-hint">分区可以提高并发性能,但会增加资源消耗</div>
|
||||
<div class="form-hint">{{ t("mqTopics.partitionHint") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">创建</button>
|
||||
<button @click="showCreateDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
|
||||
<button @click="handleCreate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqTopics.create") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,24 +342,24 @@ watch(includeNonPersistent, () => {
|
||||
<div v-if="showPartitionsDialog" class="dialog-overlay" @click="showPartitionsDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
<div class="dialog-header">
|
||||
<h3>调整分区数: {{ editingTopic?.shortName }}</h3>
|
||||
<h3>{{ t("mqTopics.updatePartitionsTitle", { name: editingTopic?.shortName }) }}</h3>
|
||||
<button @click="showPartitionsDialog = false" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-group">
|
||||
<label>当前分区数</label>
|
||||
<label>{{ t("mqTopics.currentPartitions") }}</label>
|
||||
<input type="number" :value="editingTopic?.partitions" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新分区数*</label>
|
||||
<input v-model.number="newPartitions" type="number" :min="(editingTopic?.partitions || 0) + 1" max="256" :disabled="readOnly" />
|
||||
<div class="form-hint">⚠️ 分区数只能增加,不能减少</div>
|
||||
<label>{{ t("mqTopics.newPartitions") }}*</label>
|
||||
<input v-model.number="newPartitions" type="number" :min="editingCurrentPartitions + 1" max="256" :disabled="readOnly" @change="normalizePartitionInput" @blur="normalizePartitionInput" />
|
||||
<div class="form-hint">{{ t("mqTopics.partitionMinHint", { min: editingCurrentPartitions + 1 }) }}</div>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
<div v-if="dialogError" class="form-error">{{ dialogError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button @click="showPartitionsDialog = false" class="btn-secondary">取消</button>
|
||||
<button @click="handleUpdatePartitions" :disabled="loading || readOnly" class="btn-primary">更新</button>
|
||||
<button @click="showPartitionsDialog = false" class="btn-secondary">{{ t("mqTopics.cancel") }}</button>
|
||||
<button @click="handleUpdatePartitions" :disabled="loading || !canSubmitPartitionUpdate" class="btn-primary">{{ t("mqTopics.update") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,6 +368,10 @@ watch(includeNonPersistent, () => {
|
||||
|
||||
<style scoped>
|
||||
.topics-panel {
|
||||
--topics-surface: var(--card, var(--color-background, #ffffff));
|
||||
--topics-header-bg: color-mix(in srgb, var(--secondary, #f5f5f5) 86%, var(--card, #ffffff));
|
||||
--topics-border: var(--border, var(--color-border, #e5e7eb));
|
||||
--topics-border-light: color-mix(in srgb, var(--topics-border) 68%, transparent);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -348,6 +389,7 @@ watch(includeNonPersistent, () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
@@ -360,6 +402,30 @@ watch(includeNonPersistent, () => {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.topic-search {
|
||||
width: min(320px, 32vw);
|
||||
min-width: 180px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topic-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.topic-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
@@ -387,35 +453,56 @@ watch(includeNonPersistent, () => {
|
||||
}
|
||||
|
||||
.topics-table {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-background);
|
||||
background: var(--topics-surface);
|
||||
}
|
||||
|
||||
.topics-table::before {
|
||||
content: "";
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: block;
|
||||
height: 38px;
|
||||
margin-bottom: -38px;
|
||||
background: var(--topics-header-bg);
|
||||
z-index: 9;
|
||||
box-shadow:
|
||||
0 1px 0 var(--topics-border),
|
||||
0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
table {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-background-secondary);
|
||||
z-index: 1;
|
||||
background: var(--topics-header-bg);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
z-index: 11;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: 0 1px 0 var(--color-border);
|
||||
background: var(--topics-header-bg);
|
||||
border-bottom: 1px solid var(--topics-border);
|
||||
background-clip: padding-box;
|
||||
box-shadow:
|
||||
0 1px 0 var(--topics-border),
|
||||
0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
@@ -427,13 +514,22 @@ tbody tr:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
tbody tr.selected td {
|
||||
background: var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
border-bottom: 1px solid var(--topics-border-light);
|
||||
background: var(--topics-surface);
|
||||
}
|
||||
|
||||
.topic-name-cell {
|
||||
|
||||
Reference in new issue
Block a user