1546 lines
51 KiB
Vue
1546 lines
51 KiB
Vue
<script setup lang="ts">
|
|
import { formatError } from "@/lib/backend/errorUtils";
|
|
import { computed, ref, watch, onMounted, onUnmounted } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { use } from "echarts/core";
|
|
import { CanvasRenderer } from "echarts/renderers";
|
|
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 { MqSystemKind, TopicRef, TopicInfo, TopicStats, BacklogStats } from "@/types/mq";
|
|
import { mqGetTopicStats, mqGetBacklog } from "@/lib/backend/api";
|
|
import { extractKafkaPartitionRows, isKafkaStatsPayload, type KafkaPartitionStatsRow } from "@/lib/mq/kafkaTopicStats";
|
|
import MessageBrowser from "./MessageBrowser.vue";
|
|
|
|
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]);
|
|
|
|
interface Props {
|
|
connectionId: string;
|
|
topic?: TopicInfo;
|
|
tenant?: string;
|
|
namespace?: string;
|
|
mqSystemKind?: MqSystemKind;
|
|
}
|
|
|
|
interface MetricPoint {
|
|
time: string;
|
|
msgRateIn: number | null;
|
|
msgRateOut: number | null;
|
|
backlogSize: number;
|
|
msgBacklog: number;
|
|
consumerLagMs: number;
|
|
}
|
|
|
|
interface PartitionStatsRow {
|
|
name: string;
|
|
shortName: string;
|
|
msgRateIn: number;
|
|
msgRateOut: number;
|
|
msgThroughputIn: number;
|
|
msgThroughputOut: number;
|
|
backlogSize: number;
|
|
msgBacklog: number;
|
|
storageSize: number;
|
|
producerCount: number;
|
|
subscriptionCount: number;
|
|
raw: Record<string, unknown>;
|
|
}
|
|
|
|
interface RocketMqPartitionStatsRow {
|
|
partition: number;
|
|
brokerName: string;
|
|
beginOffset: number;
|
|
endOffset: number;
|
|
messageCount: number;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const emit = defineEmits<{
|
|
"navigate-tab": [payload: { tab: "messages"; topic?: TopicInfo }];
|
|
}>();
|
|
const { t } = useI18n();
|
|
|
|
const stats = ref<TopicStats>();
|
|
const backlog = ref<BacklogStats>();
|
|
const loading = ref(false);
|
|
const error = ref<string>();
|
|
const autoRefresh = ref(true);
|
|
const refreshInterval = ref(5); // seconds
|
|
const selectedPartitionName = ref<string>();
|
|
|
|
let refreshTimer: number | undefined;
|
|
const history = ref<MetricPoint[]>([]);
|
|
const MAX_HISTORY_POINTS = 60;
|
|
const messageFlowStatus = computed(() => {
|
|
if (!stats.value || stats.value.ratesUnavailable) {
|
|
return { kind: "unavailable", label: "-" };
|
|
}
|
|
const active = stats.value.msgRateIn > 0 || stats.value.msgRateOut > 0;
|
|
return {
|
|
kind: active ? "healthy" : "idle",
|
|
label: active ? t("mqMonitoring.flowActive") : t("mqMonitoring.flowIdle"),
|
|
};
|
|
});
|
|
|
|
const partitionRows = computed(() => extractPartitionRows(stats.value?.raw));
|
|
const kafkaPartitionRows = computed(() => extractKafkaPartitionRows(stats.value?.raw));
|
|
const rocketMqPartitionRows = computed(() => extractRocketMqPartitionRows(stats.value?.raw));
|
|
const isRocketMqStats = computed(() => props.mqSystemKind === "rocketmq" && rocketMqPartitionRows.value.length > 0);
|
|
const isKafkaStats = computed(() => props.mqSystemKind === "kafka" && kafkaPartitionRows.value.length > 0);
|
|
const rocketMqOverview = computed(() => {
|
|
const raw = objectRecord(stats.value?.raw);
|
|
const rows = rocketMqPartitionRows.value;
|
|
const totalMessages = numberField(raw.totalMessages) ?? rows.reduce((sum, row) => sum + row.messageCount, 0);
|
|
return {
|
|
queueCount: numberField(raw.partitions) ?? rows.length,
|
|
totalMessages,
|
|
brokerCount: new Set(rows.map((row) => row.brokerName).filter(Boolean)).size,
|
|
};
|
|
});
|
|
const kafkaOverview = computed(() => {
|
|
const raw = objectRecord(stats.value?.raw);
|
|
const rows = kafkaPartitionRows.value;
|
|
const totalMessages = numberField(raw.totalMessages) ?? rows.reduce((sum, row) => sum + row.messageCount, 0);
|
|
const totalBeginOffset = rows.reduce((sum, row) => sum + row.beginOffset, 0);
|
|
const totalEndOffset = rows.reduce((sum, row) => sum + row.endOffset, 0);
|
|
const underReplicatedPartitions = rows.filter((row) => row.replicas.length > 0 && row.isr.length < row.replicas.length).length;
|
|
const offlinePartitions = rows.filter((row) => row.leader < 0).length;
|
|
const healthyPartitions = rows.filter((row) => row.leader >= 0 && (row.replicas.length === 0 || row.isr.length === row.replicas.length)).length;
|
|
return {
|
|
partitionCount: numberField(raw.partitions) ?? rows.length,
|
|
replicationFactor: numberField(raw.replicationFactor) ?? Math.max(0, ...rows.map((row) => row.replicas.length)),
|
|
totalMessages,
|
|
totalBeginOffset,
|
|
totalEndOffset,
|
|
leaderCount: new Set(rows.map((row) => row.leader).filter((leader) => leader >= 0)).size,
|
|
underReplicatedPartitions,
|
|
offlinePartitions,
|
|
healthyPartitions,
|
|
};
|
|
});
|
|
const selectedPartition = computed(() => partitionRows.value.find((row) => row.name === selectedPartitionName.value) ?? partitionRows.value[0]);
|
|
const selectedPartitionPublishers = computed(() => arrayObjects(selectedPartition.value?.raw.publishers));
|
|
const selectedPartitionSubscriptions = computed(() => {
|
|
const subscriptions = objectRecord(selectedPartition.value?.raw.subscriptions);
|
|
return Object.entries(subscriptions).map(([name, value]) => {
|
|
const body = objectRecord(value);
|
|
return {
|
|
name,
|
|
type: stringField(body.type),
|
|
msgBacklog: numberField(body.msgBacklog) ?? 0,
|
|
msgRateOut: numberField(body.msgRateOut) ?? 0,
|
|
consumerCount: arrayObjects(body.consumers).length,
|
|
};
|
|
});
|
|
});
|
|
|
|
const rateChartOption = computed(() => ({
|
|
tooltip: { trigger: "axis" },
|
|
legend: { top: 0, data: [t("mqMonitoring.chartLegendIn"), t("mqMonitoring.chartLegendOut")] },
|
|
grid: { left: 48, right: 18, top: 36, bottom: 32 },
|
|
xAxis: { type: "category", boundaryGap: false, data: history.value.map((point) => point.time) },
|
|
yAxis: { type: "value", name: t("mqMonitoring.chartAxisMsgPerSec") },
|
|
series: [
|
|
{ name: t("mqMonitoring.chartLegendIn"), type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgRateIn) },
|
|
{ name: t("mqMonitoring.chartLegendOut"), type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgRateOut) },
|
|
],
|
|
}));
|
|
|
|
const backlogChartOption = computed(() => ({
|
|
tooltip: { trigger: "axis" },
|
|
legend: { top: 0, data: [t("mqMonitoring.chartLegendMessages"), t("mqMonitoring.chartLegendBytes")] },
|
|
grid: { left: 56, right: 54, top: 36, bottom: 32 },
|
|
xAxis: { type: "category", boundaryGap: false, data: history.value.map((point) => point.time) },
|
|
yAxis: [
|
|
{ type: "value", name: t("mqMonitoring.chartAxisMsg") },
|
|
{ type: "value", name: t("mqMonitoring.chartAxisBytes") },
|
|
],
|
|
series: [
|
|
{ name: t("mqMonitoring.chartLegendMessages"), type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgBacklog) },
|
|
{ name: t("mqMonitoring.chartLegendBytes"), type: "line", smooth: true, showSymbol: false, yAxisIndex: 1, data: history.value.map((point) => point.backlogSize) },
|
|
],
|
|
}));
|
|
|
|
const latencyChartOption = computed(() => ({
|
|
tooltip: { trigger: "axis" },
|
|
legend: { top: 0, data: [t("mqMonitoring.chartLegendConsumerLag")] },
|
|
grid: { left: 56, right: 18, top: 36, bottom: 32 },
|
|
xAxis: { type: "category", boundaryGap: false, data: history.value.map((point) => point.time) },
|
|
yAxis: { type: "value", name: t("mqMonitoring.chartAxisMs") },
|
|
series: [{ name: t("mqMonitoring.chartLegendConsumerLag"), type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.consumerLagMs) }],
|
|
}));
|
|
|
|
function getTopicRef(): TopicRef | null {
|
|
if (!props.topic || !props.tenant || !props.namespace) return null;
|
|
return {
|
|
tenant: props.tenant,
|
|
namespace: props.topic.namespace || props.namespace,
|
|
topic: props.topic.shortName,
|
|
persistent: props.topic.persistent,
|
|
partitioned: props.topic.partitioned,
|
|
};
|
|
}
|
|
|
|
function isDocumentHidden(): boolean {
|
|
return typeof document !== "undefined" && document.hidden;
|
|
}
|
|
|
|
async function loadStats(options: { skipWhenHidden?: boolean } = {}) {
|
|
if (options.skipWhenHidden && isDocumentHidden()) return;
|
|
const topicRef = getTopicRef();
|
|
if (!topicRef) {
|
|
stats.value = undefined;
|
|
backlog.value = undefined;
|
|
history.value = [];
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
error.value = undefined;
|
|
try {
|
|
const statsData = await mqGetTopicStats(props.connectionId, topicRef);
|
|
|
|
if (props.mqSystemKind === "rocketmq" || (props.mqSystemKind === "kafka" && isKafkaStatsPayload(statsData.raw))) {
|
|
stats.value = statsData;
|
|
backlog.value = undefined;
|
|
appendHistoryPoint(statsData);
|
|
return;
|
|
}
|
|
|
|
const backlogData = await mqGetBacklog(props.connectionId, topicRef, undefined);
|
|
stats.value = statsData;
|
|
backlog.value = backlogData;
|
|
appendHistoryPoint(statsData, backlogData);
|
|
} catch (e: unknown) {
|
|
error.value = formatError(e);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function refreshNow() {
|
|
void loadStats();
|
|
}
|
|
|
|
function appendHistoryPoint(statsData: TopicStats, backlogData?: BacklogStats) {
|
|
const ratesUnavailable = statsData.ratesUnavailable === true;
|
|
const point: MetricPoint = {
|
|
time: new Date().toLocaleTimeString(),
|
|
msgRateIn: ratesUnavailable ? null : statsData.msgRateIn,
|
|
msgRateOut: ratesUnavailable ? null : statsData.msgRateOut,
|
|
backlogSize: statsData.backlogSize,
|
|
msgBacklog: backlogData?.msgBacklog ?? 0,
|
|
consumerLagMs: extractConsumerLagMs(statsData.raw),
|
|
};
|
|
history.value = [...history.value.slice(-(MAX_HISTORY_POINTS - 1)), point];
|
|
}
|
|
|
|
function extractConsumerLagMs(raw: unknown): number {
|
|
if (!raw || typeof raw !== "object") return 0;
|
|
const subscriptions = (raw as { subscriptions?: unknown }).subscriptions;
|
|
if (!subscriptions || typeof subscriptions !== "object") return 0;
|
|
const now = Date.now();
|
|
let maxLag = 0;
|
|
for (const subscription of Object.values(subscriptions as Record<string, unknown>)) {
|
|
if (!subscription || typeof subscription !== "object") continue;
|
|
const data = subscription as Record<string, unknown>;
|
|
const timestamp = numberField(data.lastAckedTimestamp) ?? numberField(data.lastConsumedTimestamp) ?? numberField(data.lastMarkDeleteAdvancedTimestamp);
|
|
if (timestamp && timestamp > 0 && timestamp <= now) {
|
|
maxLag = Math.max(maxLag, now - timestamp);
|
|
}
|
|
}
|
|
return maxLag;
|
|
}
|
|
|
|
function extractPartitionRows(raw: unknown): PartitionStatsRow[] {
|
|
const root = objectRecord(raw);
|
|
const partitions = objectRecord(root.partitions);
|
|
return Object.entries(partitions).map(([name, value]) => {
|
|
const body = objectRecord(value);
|
|
return {
|
|
name,
|
|
shortName: partitionShortName(name),
|
|
msgRateIn: numberField(body.msgRateIn) ?? 0,
|
|
msgRateOut: numberField(body.msgRateOut) ?? 0,
|
|
msgThroughputIn: numberField(body.msgThroughputIn) ?? 0,
|
|
msgThroughputOut: numberField(body.msgThroughputOut) ?? 0,
|
|
backlogSize: numberField(body.backlogSize) ?? 0,
|
|
msgBacklog: partitionBacklogMessages(body),
|
|
storageSize: numberField(body.storageSize) ?? 0,
|
|
producerCount: arrayObjects(body.publishers).length,
|
|
subscriptionCount: Object.keys(objectRecord(body.subscriptions)).length,
|
|
raw: body,
|
|
};
|
|
});
|
|
}
|
|
|
|
function extractRocketMqPartitionRows(raw: unknown): RocketMqPartitionStatsRow[] {
|
|
return arrayObjects(objectRecord(raw).partitionStats)
|
|
.map((body) => ({
|
|
partition: numberField(body.partition) ?? 0,
|
|
brokerName: stringField(body.brokerName),
|
|
beginOffset: numberField(body.beginOffset) ?? 0,
|
|
endOffset: numberField(body.endOffset) ?? 0,
|
|
messageCount: numberField(body.messageCount) ?? 0,
|
|
}))
|
|
.sort((a, b) => a.partition - b.partition || a.brokerName.localeCompare(b.brokerName));
|
|
}
|
|
|
|
function isKafkaPartitionHealthy(row: KafkaPartitionStatsRow): boolean {
|
|
return row.leader >= 0 && (row.replicas.length === 0 || row.isr.length >= row.replicas.length);
|
|
}
|
|
|
|
function kafkaPartitionStatusLabel(row: KafkaPartitionStatsRow): string {
|
|
if (row.leader < 0) return t("mqMonitoring.statusNoLeader");
|
|
if (row.replicas.length > 0 && row.isr.length < row.replicas.length) return t("mqMonitoring.statusIsrIncomplete");
|
|
return t("mqMonitoring.statusHealthy");
|
|
}
|
|
|
|
function partitionBacklogMessages(body: Record<string, unknown>): number {
|
|
const direct = numberField(body.msgBacklog);
|
|
if (direct !== undefined) return direct;
|
|
return Object.values(objectRecord(body.subscriptions)).reduce<number>((sum, value) => {
|
|
return sum + (numberField(objectRecord(value).msgBacklog) ?? 0);
|
|
}, 0);
|
|
}
|
|
|
|
function partitionShortName(name: string): string {
|
|
const path = name.includes("://") ? name.split("://", 2)[1] || name : name;
|
|
return path.split("/").slice(-1)[0] || name;
|
|
}
|
|
|
|
function objectRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function arrayObjects(value: unknown): Record<string, unknown>[] {
|
|
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => !!item && typeof item === "object" && !Array.isArray(item)) : [];
|
|
}
|
|
|
|
function numberField(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
function stringField(value: unknown): string {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function startAutoRefresh() {
|
|
stopAutoRefresh();
|
|
if (autoRefresh.value && props.topic && !isDocumentHidden()) {
|
|
refreshTimer = window.setInterval(() => {
|
|
void loadStats({ skipWhenHidden: true });
|
|
}, refreshInterval.value * 1000);
|
|
}
|
|
}
|
|
|
|
function stopAutoRefresh() {
|
|
if (refreshTimer !== undefined) {
|
|
clearInterval(refreshTimer);
|
|
refreshTimer = undefined;
|
|
}
|
|
}
|
|
|
|
function handleVisibilityChange() {
|
|
if (isDocumentHidden()) {
|
|
stopAutoRefresh();
|
|
return;
|
|
}
|
|
startAutoRefresh();
|
|
void loadStats();
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return "0 B";
|
|
const k = 1024;
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
|
}
|
|
|
|
function formatNumber(num: number): string {
|
|
return num.toLocaleString();
|
|
}
|
|
|
|
function formatMessageRate(rate: number, unavailable?: boolean): string {
|
|
return unavailable ? "-" : `${rate.toFixed(2)} msg/s`;
|
|
}
|
|
|
|
watch(
|
|
() => props.topic,
|
|
() => {
|
|
history.value = [];
|
|
selectedPartitionName.value = undefined;
|
|
void loadStats();
|
|
startAutoRefresh();
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(partitionRows, (rows) => {
|
|
if (!rows.length) {
|
|
selectedPartitionName.value = undefined;
|
|
return;
|
|
}
|
|
if (!selectedPartitionName.value || !rows.some((row) => row.name === selectedPartitionName.value)) {
|
|
selectedPartitionName.value = rows[0].name;
|
|
}
|
|
});
|
|
|
|
watch(autoRefresh, () => {
|
|
startAutoRefresh();
|
|
});
|
|
|
|
watch(refreshInterval, () => {
|
|
if (autoRefresh.value) {
|
|
startAutoRefresh();
|
|
}
|
|
});
|
|
|
|
onMounted(() => {
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
startAutoRefresh();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
stopAutoRefresh();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="monitoring-panel">
|
|
<div class="panel-toolbar">
|
|
<h3>{{ t("mqMonitoring.title") }}</h3>
|
|
<div class="toolbar-actions">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" v-model="autoRefresh" />
|
|
<span>{{ t("mqMonitoring.autoRefresh") }}</span>
|
|
</label>
|
|
<select v-model.number="refreshInterval" :disabled="!autoRefresh" class="refresh-interval">
|
|
<option :value="5">{{ t("mqMonitoring.refreshInterval5s") }}</option>
|
|
<option :value="10">{{ t("mqMonitoring.refreshInterval10s") }}</option>
|
|
<option :value="30">{{ t("mqMonitoring.refreshInterval30s") }}</option>
|
|
<option :value="60">{{ t("mqMonitoring.refreshInterval60s") }}</option>
|
|
</select>
|
|
<button @click="refreshNow" :disabled="loading" class="btn-sm">
|
|
<Loader2 v-if="loading" class="btn-icon spinning" :size="14" />
|
|
<RefreshCw v-else class="btn-icon" :size="14" />
|
|
<span>{{ loading ? t("mqMonitoring.refreshing") : t("mqMonitoring.refreshNow") }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="error && topic" class="panel-error">
|
|
<AlertTriangle :size="18" />
|
|
<span>{{ error }}</span>
|
|
</div>
|
|
|
|
<div v-if="!topic" class="panel-placeholder">
|
|
<Table2 :size="24" />
|
|
<span>{{ t("mqMonitoring.selectTopicFirst") }}</span>
|
|
</div>
|
|
|
|
<div v-else-if="loading && !stats" class="panel-loading">
|
|
<Loader2 class="loading-icon spinning" :size="22" />
|
|
<span>{{ t("mqMonitoring.loadingStats") }}</span>
|
|
<div class="loading-skeleton-grid" aria-hidden="true">
|
|
<div v-for="item in 4" :key="item" class="loading-skeleton-card"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="stats && isRocketMqStats" class="stats-container">
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.rocketmqTopicOverview") }}</h4>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Layers3 :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.queueCount") }}</div>
|
|
<div class="stat-value">{{ rocketMqOverview.queueCount }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Hash :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.messageCount") }}</div>
|
|
<div class="stat-value">{{ formatNumber(rocketMqOverview.totalMessages) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><HardDrive :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.brokerCount") }}</div>
|
|
<div class="stat-value">{{ rocketMqOverview.brokerCount }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.rocketmqQueueDetails") }}</h4>
|
|
<div v-if="rocketMqPartitionRows.length" class="partition-table-wrap">
|
|
<table class="partition-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t("mqMonitoring.tableQueue") }}</th>
|
|
<th>{{ t("mqMonitoring.tableBroker") }}</th>
|
|
<th>{{ t("mqMonitoring.tableBeginOffset") }}</th>
|
|
<th>{{ t("mqMonitoring.tableLogEndOffset") }}</th>
|
|
<th>{{ t("mqMonitoring.tableMessageCount") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in rocketMqPartitionRows" :key="`${row.brokerName}-${row.partition}`">
|
|
<td>{{ row.partition }}</td>
|
|
<td>{{ row.brokerName || "-" }}</td>
|
|
<td>{{ formatNumber(row.beginOffset) }}</td>
|
|
<td>{{ formatNumber(row.endOffset) }}</td>
|
|
<td>{{ formatNumber(row.messageCount) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div v-else class="empty-state compact">{{ t("mqMonitoring.noRocketMqQueueMetrics") }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="stats && isKafkaStats" class="stats-container">
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.kafkaTopicOverview") }}</h4>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Layers3 :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.partitionCount") }}</div>
|
|
<div class="stat-value">{{ kafkaOverview.partitionCount }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Boxes :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.replicationFactor") }}</div>
|
|
<div class="stat-value">{{ kafkaOverview.replicationFactor }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Hash :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.messageCount") }}</div>
|
|
<div class="stat-value">{{ formatNumber(kafkaOverview.totalMessages) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><BarChart3 :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.logEndOffset") }}</div>
|
|
<div class="stat-value">{{ formatNumber(kafkaOverview.totalEndOffset) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.offsetAndReplicaStatus") }}</h4>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Gauge :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.beginOffset") }}</div>
|
|
<div class="stat-value">{{ formatNumber(kafkaOverview.totalBeginOffset) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><RadioTower :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.leaderCount") }}</div>
|
|
<div class="stat-value">{{ kafkaOverview.leaderCount }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card" :class="{ warning: kafkaOverview.underReplicatedPartitions > 0 }">
|
|
<div class="stat-icon"><ShieldCheck :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.isrHealthyPartitions") }}</div>
|
|
<div class="stat-value">{{ kafkaOverview.healthyPartitions }} / {{ kafkaOverview.partitionCount }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card" :class="{ warning: kafkaOverview.offlinePartitions > 0 }">
|
|
<div class="stat-icon"><AlertTriangle :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.noLeaderPartitions") }}</div>
|
|
<div class="stat-value">{{ kafkaOverview.offlinePartitions }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.kafkaPartitionDetails") }}</h4>
|
|
<div v-if="kafkaPartitionRows.length" class="partition-layout">
|
|
<div class="partition-table-wrap">
|
|
<table class="partition-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t("mqMonitoring.tablePartition") }}</th>
|
|
<th>{{ t("mqMonitoring.tableBeginOffset") }}</th>
|
|
<th>{{ t("mqMonitoring.tableLogEndOffset") }}</th>
|
|
<th>{{ t("mqMonitoring.tableMessageCount") }}</th>
|
|
<th>{{ t("mqMonitoring.tableLeader") }}</th>
|
|
<th>{{ t("mqMonitoring.tableReplicas") }}</th>
|
|
<th>{{ t("mqMonitoring.tableIsr") }}</th>
|
|
<th>{{ t("mqMonitoring.tableStatus") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="partition in kafkaPartitionRows" :key="partition.partition" :class="{ warning: partition.leader < 0 || (partition.replicas.length > 0 && partition.isr.length < partition.replicas.length) }">
|
|
<td>{{ partition.partition }}</td>
|
|
<td>{{ formatNumber(partition.beginOffset) }}</td>
|
|
<td>{{ formatNumber(partition.endOffset) }}</td>
|
|
<td>{{ formatNumber(partition.messageCount) }}</td>
|
|
<td>{{ partition.leader >= 0 ? partition.leader : "-" }}</td>
|
|
<td>{{ partition.replicas.join(", ") || "-" }}</td>
|
|
<td>{{ partition.isr.join(", ") || "-" }}</td>
|
|
<td>
|
|
<span :class="['table-status', isKafkaPartitionHealthy(partition) ? 'healthy' : 'warning']">
|
|
{{ kafkaPartitionStatusLabel(partition) }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div v-else class="empty-state compact">{{ t("mqMonitoring.noKafkaPartitionMetrics") }}</div>
|
|
</div>
|
|
|
|
<div class="stats-section">
|
|
<div class="monitoring-message-heading">
|
|
<button type="button" class="btn-sm" @click="emit('navigate-tab', { tab: 'messages', topic })">{{ t("mqMessages.queryTitle") }}</button>
|
|
</div>
|
|
<MessageBrowser :connection-id="connectionId" :topic="getTopicRef()" mq-system-kind="kafka" appearance="monitoring" />
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="stats" class="stats-container">
|
|
<!-- Overview Section -->
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.messageRate") }}</h4>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Download :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.inboundRate") }}</div>
|
|
<div class="stat-value" data-testid="monitoring-rate-in">{{ formatMessageRate(stats.msgRateIn, stats.ratesUnavailable) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Upload :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.outboundRate") }}</div>
|
|
<div class="stat-value" data-testid="monitoring-rate-out">{{ formatMessageRate(stats.msgRateOut, stats.ratesUnavailable) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Activity :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.inboundThroughput") }}</div>
|
|
<div class="stat-value">{{ formatBytes(stats.msgThroughputIn) }}/s</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><BarChart3 :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.outboundThroughput") }}</div>
|
|
<div class="stat-value">{{ formatBytes(stats.msgThroughputOut) }}/s</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="charts-grid">
|
|
<div class="chart-panel">
|
|
<h4>{{ t("mqMonitoring.rateTrend") }}</h4>
|
|
<VChart :option="rateChartOption" autoresize class="trend-chart" data-testid="monitoring-rate-chart" />
|
|
</div>
|
|
<div class="chart-panel">
|
|
<h4>{{ t("mqMonitoring.backlogTrend") }}</h4>
|
|
<VChart :option="backlogChartOption" autoresize class="trend-chart" />
|
|
</div>
|
|
<div class="chart-panel">
|
|
<h4>{{ t("mqMonitoring.consumerLag") }}</h4>
|
|
<VChart :option="latencyChartOption" autoresize class="trend-chart" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Storage Section -->
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.storageAndBacklog") }}</h4>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Database :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.storageSize") }}</div>
|
|
<div class="stat-value">{{ formatBytes(stats.storageSize) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card" :class="{ warning: stats.backlogSize > 10 * 1024 * 1024 }">
|
|
<div class="stat-icon"><Package :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.backlogSize") }}</div>
|
|
<div class="stat-value">{{ formatBytes(stats.backlogSize) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card" v-if="backlog">
|
|
<div class="stat-icon"><HardDrive :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.backlogMessageCount") }}</div>
|
|
<div class="stat-value">{{ formatNumber(backlog.msgBacklog) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Counters Section -->
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.messageCounters") }}</h4>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Send :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.publishedMessages") }}</div>
|
|
<div class="stat-value">{{ formatNumber(stats.msgInCounter) }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><CheckCircle2 :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.consumedMessages") }}</div>
|
|
<div class="stat-value">{{ formatNumber(stats.msgOutCounter) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connections Section -->
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.connectionStats") }}</h4>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><Users :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.subscriptionCount") }}</div>
|
|
<div class="stat-value">{{ stats.subscriptionCount }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon"><RadioTower :size="21" /></div>
|
|
<div class="stat-content">
|
|
<div class="stat-label">{{ t("mqMonitoring.producerCount") }}</div>
|
|
<div class="stat-value">{{ stats.producerCount }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.partitionDetails") }}</h4>
|
|
<div v-if="partitionRows.length" class="partition-layout">
|
|
<div class="partition-table-wrap">
|
|
<table class="partition-table interactive-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t("mqMonitoring.tablePartition") }}</th>
|
|
<th>{{ t("mqMonitoring.tableInbound") }}</th>
|
|
<th>{{ t("mqMonitoring.tableOutbound") }}</th>
|
|
<th>{{ t("mqMonitoring.tableInboundThroughput") }}</th>
|
|
<th>{{ t("mqMonitoring.tableOutboundThroughput") }}</th>
|
|
<th>{{ t("mqMonitoring.tableBacklogMessages") }}</th>
|
|
<th>{{ t("mqMonitoring.tableBacklogSize") }}</th>
|
|
<th>{{ t("mqMonitoring.tableProducers") }}</th>
|
|
<th>{{ t("mqMonitoring.tableSubscriptions") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="partition in partitionRows" :key="partition.name" :class="{ selected: selectedPartition?.name === partition.name }" @click="selectedPartitionName = partition.name">
|
|
<td :title="partition.name">{{ partition.shortName }}</td>
|
|
<td>{{ partition.msgRateIn.toFixed(2) }} msg/s</td>
|
|
<td>{{ partition.msgRateOut.toFixed(2) }} msg/s</td>
|
|
<td>{{ formatBytes(partition.msgThroughputIn) }}/s</td>
|
|
<td>{{ formatBytes(partition.msgThroughputOut) }}/s</td>
|
|
<td>{{ formatNumber(partition.msgBacklog) }}</td>
|
|
<td>{{ formatBytes(partition.backlogSize) }}</td>
|
|
<td>{{ partition.producerCount }}</td>
|
|
<td>{{ partition.subscriptionCount }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="selectedPartition" class="partition-detail">
|
|
<h5>{{ selectedPartition.shortName }}</h5>
|
|
<div class="detail-grid">
|
|
<div>
|
|
<div class="detail-title">{{ t("mqMonitoring.producers") }}</div>
|
|
<table v-if="selectedPartitionPublishers.length" class="detail-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t("mqMonitoring.tableName") }}</th>
|
|
<th>{{ t("mqMonitoring.tableRate") }}</th>
|
|
<th>{{ t("mqMonitoring.tableAddress") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="publisher in selectedPartitionPublishers" :key="String(publisher.producerName ?? publisher.producerId ?? publisher.address)">
|
|
<td>{{ publisher.producerName || publisher.producerId || "-" }}</td>
|
|
<td>{{ (numberField(publisher.msgRateIn) ?? 0).toFixed(2) }} msg/s</td>
|
|
<td>{{ publisher.address || "-" }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div v-else class="empty-state compact">{{ t("mqMonitoring.noProducers") }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="detail-title">{{ t("mqMonitoring.subscriptions") }}</div>
|
|
<table v-if="selectedPartitionSubscriptions.length" class="detail-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t("mqMonitoring.tableName") }}</th>
|
|
<th>{{ t("mqMonitoring.tableType") }}</th>
|
|
<th>{{ t("mqMonitoring.tableBacklog") }}</th>
|
|
<th>{{ t("mqMonitoring.tableConsumers") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="subscription in selectedPartitionSubscriptions" :key="subscription.name">
|
|
<td>{{ subscription.name }}</td>
|
|
<td>{{ subscription.type || "-" }}</td>
|
|
<td>{{ formatNumber(subscription.msgBacklog) }}</td>
|
|
<td>{{ subscription.consumerCount }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div v-else class="empty-state compact">{{ t("mqMonitoring.noSubscriptions") }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="empty-state compact">
|
|
{{ topic.partitioned ? t("mqMonitoring.noPartitionMetricsFromBroker") : t("mqMonitoring.nonPartitionedTopicNoDetails") }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Health Indicators -->
|
|
<div class="stats-section">
|
|
<h4>{{ t("mqMonitoring.healthIndicators") }}</h4>
|
|
<div class="health-indicators">
|
|
<div class="health-item">
|
|
<span class="health-label">{{ t("mqMonitoring.messageFlow") }}:</span>
|
|
<span :class="['health-badge', messageFlowStatus.kind]" data-testid="monitoring-flow-status">
|
|
{{ messageFlowStatus.label }}
|
|
</span>
|
|
</div>
|
|
<div class="health-item">
|
|
<span class="health-label">{{ t("mqMonitoring.backlogStatus") }}:</span>
|
|
<span :class="['health-badge', stats.backlogSize < 10 * 1024 * 1024 ? 'healthy' : 'warning']">
|
|
{{ stats.backlogSize < 10 * 1024 * 1024 ? t("mqMonitoring.backlogNormal") : t("mqMonitoring.backlogHigh") }}
|
|
</span>
|
|
</div>
|
|
<div class="health-item">
|
|
<span class="health-label">{{ t("mqMonitoring.producersLabel") }}:</span>
|
|
<span :class="['health-badge', stats.producerCount > 0 ? 'healthy' : 'idle']">
|
|
{{ stats.producerCount > 0 ? t("mqMonitoring.producerConnected") : t("mqMonitoring.producerDisconnected") }}
|
|
</span>
|
|
</div>
|
|
<div class="health-item">
|
|
<span class="health-label">{{ t("mqMonitoring.subscriptionsLabel") }}:</span>
|
|
<span :class="['health-badge', stats.subscriptionCount > 0 ? 'healthy' : 'idle']">
|
|
{{ stats.subscriptionCount > 0 ? t("mqMonitoring.subscriptionActive") : t("mqMonitoring.subscriptionNone") }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.monitoring-panel {
|
|
--monitor-panel-bg: color-mix(in srgb, var(--color-background, #ffffff) 94%, var(--color-muted, #f5f5f5));
|
|
--monitor-surface: var(--color-background-secondary, var(--color-card, #ffffff));
|
|
--monitor-surface-raised: color-mix(in srgb, var(--monitor-surface) 96%, var(--monitor-accent) 4%);
|
|
--monitor-border: color-mix(in srgb, var(--color-border, #e5e7eb) 76%, transparent);
|
|
--monitor-border-strong: color-mix(in srgb, var(--color-border, #d4d4d8) 88%, var(--monitor-accent) 12%);
|
|
--monitor-text: var(--color-text, var(--color-foreground, #18181b));
|
|
--monitor-muted: var(--color-text-secondary, var(--color-muted-foreground, #64748b));
|
|
--monitor-faint: var(--color-text-tertiary, color-mix(in srgb, var(--monitor-muted) 72%, transparent));
|
|
--monitor-hover: color-mix(in srgb, var(--color-hover, var(--color-muted, #f4f4f5)) 72%, var(--monitor-accent) 7%);
|
|
--monitor-accent: var(--info);
|
|
--monitor-accent-soft: var(--info-bg);
|
|
--monitor-accent-border: color-mix(in srgb, var(--info) 22%, transparent);
|
|
--monitor-success: var(--success);
|
|
--monitor-success-soft: var(--success-bg);
|
|
--monitor-warning: var(--warning);
|
|
--monitor-warning-soft: var(--warning-bg);
|
|
--monitor-danger: var(--destructive);
|
|
--monitor-danger-soft: color-mix(in srgb, var(--destructive) 10%, transparent);
|
|
--monitor-shadow: 0 18px 38px -28px rgb(15 23 42 / 0.42);
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: linear-gradient(180deg, color-mix(in srgb, var(--monitor-panel-bg) 88%, var(--monitor-accent-soft)) 0%, var(--monitor-panel-bg) 38%), var(--monitor-panel-bg);
|
|
color: var(--monitor-text);
|
|
font-family: var(--font-sans, "Geist Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif);
|
|
}
|
|
|
|
:global(.dark) .monitoring-panel {
|
|
--monitor-panel-bg: color-mix(in srgb, var(--color-background, #131416) 90%, var(--info) 4%);
|
|
--monitor-surface: color-mix(in srgb, var(--color-card, #1b1b1e) 94%, #ffffff 2%);
|
|
--monitor-surface-raised: color-mix(in srgb, var(--monitor-surface) 92%, var(--info) 8%);
|
|
--monitor-border: color-mix(in srgb, var(--color-border, rgb(110 110 114 / 0.28)) 82%, transparent);
|
|
--monitor-border-strong: color-mix(in srgb, var(--monitor-border) 76%, var(--info) 24%);
|
|
--monitor-accent: var(--info);
|
|
--monitor-accent-soft: var(--info-bg);
|
|
--monitor-accent-border: color-mix(in srgb, var(--info) 24%, transparent);
|
|
--monitor-success: var(--success);
|
|
--monitor-success-soft: var(--success-bg);
|
|
--monitor-warning: var(--warning);
|
|
--monitor-warning-soft: var(--warning-bg);
|
|
--monitor-danger: var(--destructive);
|
|
--monitor-danger-soft: color-mix(in srgb, var(--destructive) 12%, transparent);
|
|
--monitor-shadow: 0 18px 42px -28px rgb(0 0 0 / 0.72);
|
|
}
|
|
|
|
.panel-toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 14px 20px;
|
|
border-bottom: 1px solid var(--monitor-border);
|
|
background: var(--monitor-surface);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
.panel-toolbar h3 {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
line-height: 1.3;
|
|
font-weight: 650;
|
|
color: var(--monitor-text);
|
|
text-wrap: balance;
|
|
}
|
|
|
|
.toolbar-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-height: 32px;
|
|
padding: 5px 9px;
|
|
border: 1px solid transparent;
|
|
border-radius: var(--dbx-radius-fixed-4);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--monitor-muted);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition:
|
|
background 0.2s ease,
|
|
color 0.2s ease,
|
|
border-color 0.2s ease;
|
|
}
|
|
|
|
.checkbox-label:hover {
|
|
border-color: var(--monitor-border);
|
|
background: var(--monitor-hover);
|
|
color: var(--monitor-text);
|
|
}
|
|
|
|
.checkbox-label input {
|
|
width: 14px;
|
|
height: 14px;
|
|
accent-color: var(--monitor-accent);
|
|
}
|
|
|
|
.refresh-interval {
|
|
min-height: 34px;
|
|
padding: 5px 30px 5px 10px;
|
|
border: 1px solid var(--monitor-border);
|
|
border-radius: var(--dbx-radius-fixed-4);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
background: var(--monitor-surface);
|
|
color: var(--monitor-text);
|
|
cursor: pointer;
|
|
outline: none;
|
|
transition:
|
|
border-color 0.2s ease,
|
|
box-shadow 0.2s ease,
|
|
background 0.2s ease;
|
|
}
|
|
|
|
.refresh-interval:hover:not(:disabled) {
|
|
border-color: var(--monitor-border-strong);
|
|
}
|
|
|
|
.refresh-interval:focus-visible {
|
|
border-color: var(--monitor-accent);
|
|
box-shadow: 0 0 0 3px var(--monitor-accent-soft);
|
|
}
|
|
|
|
.refresh-interval:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-sm {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 7px;
|
|
min-height: 34px;
|
|
padding: 7px 12px;
|
|
border: 1px solid var(--monitor-border);
|
|
border-radius: var(--dbx-radius-fixed-4);
|
|
background: var(--monitor-surface);
|
|
color: var(--monitor-text);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 560;
|
|
line-height: 1;
|
|
white-space: nowrap;
|
|
box-shadow: 0 1px 0 rgb(255 255 255 / 0.55) inset;
|
|
transition:
|
|
transform 0.16s ease,
|
|
background 0.2s ease,
|
|
border-color 0.2s ease,
|
|
box-shadow 0.2s ease;
|
|
}
|
|
|
|
.btn-sm:hover:not(:disabled) {
|
|
border-color: var(--monitor-border-strong);
|
|
background: var(--monitor-hover);
|
|
box-shadow: var(--monitor-shadow);
|
|
}
|
|
|
|
/* 桌面端不做位移/缩放按压反馈,仅靠颜色变化表达状态,避免按钮"抖动"观感 */
|
|
.btn-sm:active:not(:disabled) {
|
|
background: var(--monitor-accent-soft);
|
|
border-color: var(--monitor-accent);
|
|
}
|
|
|
|
.btn-sm:focus-visible {
|
|
outline: none;
|
|
border-color: var(--monitor-accent);
|
|
box-shadow: 0 0 0 3px var(--monitor-accent-soft);
|
|
}
|
|
|
|
.btn-sm:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.btn-icon {
|
|
flex: 0 0 auto;
|
|
color: var(--monitor-accent);
|
|
}
|
|
|
|
.panel-placeholder,
|
|
.panel-error,
|
|
.panel-loading,
|
|
.empty-state {
|
|
display: grid;
|
|
place-items: center;
|
|
gap: 10px;
|
|
padding: 32px;
|
|
text-align: center;
|
|
color: var(--monitor-muted);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.panel-error {
|
|
display: flex;
|
|
justify-content: center;
|
|
color: var(--monitor-danger);
|
|
background: var(--monitor-danger-soft);
|
|
border-bottom: 1px solid color-mix(in srgb, var(--monitor-danger) 18%, transparent);
|
|
}
|
|
|
|
.panel-loading {
|
|
align-content: start;
|
|
padding-top: 48px;
|
|
}
|
|
|
|
.loading-icon {
|
|
color: var(--monitor-accent);
|
|
}
|
|
|
|
.spinning {
|
|
animation: monitor-spin 0.9s linear infinite;
|
|
}
|
|
|
|
.loading-skeleton-grid {
|
|
width: min(100%, 920px);
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.loading-skeleton-card {
|
|
height: 96px;
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.45), transparent), var(--monitor-surface);
|
|
background-size: 220% 100%;
|
|
border: 1px solid var(--monitor-border);
|
|
animation: monitor-skeleton 1.3s ease-in-out infinite;
|
|
}
|
|
|
|
.stats-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
width: min(100%, 1480px);
|
|
margin: 0 auto;
|
|
padding: 22px 24px 32px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.stats-section {
|
|
margin-bottom: 28px;
|
|
}
|
|
|
|
.stats-section:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.stats-section h4 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
margin: 0 0 14px;
|
|
font-size: 14px;
|
|
line-height: 1.35;
|
|
font-weight: 680;
|
|
color: var(--monitor-text);
|
|
}
|
|
|
|
.stats-section h4::before {
|
|
content: "";
|
|
width: 4px;
|
|
height: 16px;
|
|
border-radius: 2px;
|
|
background: var(--monitor-accent);
|
|
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;
|
|
}
|
|
|
|
.charts-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 14px;
|
|
margin-bottom: 28px;
|
|
}
|
|
|
|
.chart-panel {
|
|
min-height: 260px;
|
|
padding: 14px;
|
|
border: 1px solid var(--monitor-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: var(--monitor-surface);
|
|
box-shadow: var(--monitor-shadow);
|
|
}
|
|
|
|
.chart-panel h4 {
|
|
margin: 0 0 8px 0;
|
|
font-size: 13px;
|
|
line-height: 1.35;
|
|
font-weight: 650;
|
|
color: var(--monitor-muted);
|
|
}
|
|
|
|
.trend-chart {
|
|
width: 100%;
|
|
height: 220px;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.stat-card {
|
|
position: relative;
|
|
isolation: isolate;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
min-height: 96px;
|
|
gap: 14px;
|
|
padding: 17px 18px;
|
|
background: var(--monitor-surface);
|
|
border: 1px solid var(--monitor-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
box-shadow: 0 1px 0 rgb(255 255 255 / 0.52) inset;
|
|
transition:
|
|
transform 0.22s cubic-bezier(0.16, 1, 0.3, 1),
|
|
border-color 0.22s ease,
|
|
box-shadow 0.22s ease,
|
|
background 0.22s ease;
|
|
}
|
|
|
|
.stat-card::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0 auto 0 0;
|
|
width: 3px;
|
|
background: var(--monitor-accent);
|
|
opacity: 0.75;
|
|
z-index: 0;
|
|
}
|
|
|
|
.stat-card::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
background: linear-gradient(135deg, var(--monitor-accent-soft), transparent 42%);
|
|
opacity: 0.45;
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}
|
|
|
|
.stat-card:hover {
|
|
transform: translateY(-2px);
|
|
border-color: var(--monitor-accent-border);
|
|
background: var(--monitor-surface-raised);
|
|
box-shadow: var(--monitor-shadow);
|
|
}
|
|
|
|
.stat-card.warning {
|
|
border-color: color-mix(in srgb, var(--monitor-warning) 34%, transparent);
|
|
background: color-mix(in srgb, var(--monitor-surface) 86%, var(--monitor-warning-soft));
|
|
}
|
|
|
|
.stat-card.warning::before {
|
|
background: var(--monitor-warning);
|
|
}
|
|
|
|
.stat-card.warning .stat-icon {
|
|
color: var(--monitor-warning);
|
|
background: var(--monitor-warning-soft);
|
|
border-color: color-mix(in srgb, var(--monitor-warning) 24%, transparent);
|
|
}
|
|
|
|
.stat-icon {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: grid;
|
|
place-items: center;
|
|
width: 42px;
|
|
height: 42px;
|
|
flex: 0 0 42px;
|
|
border: 1px solid var(--monitor-accent-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: var(--monitor-accent-soft);
|
|
color: var(--monitor-accent);
|
|
}
|
|
|
|
.stat-content {
|
|
position: relative;
|
|
z-index: 1;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.stat-label {
|
|
margin-bottom: 6px;
|
|
font-size: 12px;
|
|
line-height: 1.3;
|
|
font-weight: 560;
|
|
color: var(--monitor-muted);
|
|
text-wrap: pretty;
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: "Geist Variable Tabular", var(--font-sans, system-ui, sans-serif);
|
|
font-size: 23px;
|
|
line-height: 1.1;
|
|
font-weight: 720;
|
|
color: var(--monitor-text);
|
|
font-variant-numeric: tabular-nums;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.health-indicators {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.partition-layout {
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
|
|
.partition-table-wrap {
|
|
overflow-x: auto;
|
|
border: 1px solid var(--monitor-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: var(--monitor-surface);
|
|
box-shadow: var(--monitor-shadow);
|
|
}
|
|
|
|
.partition-table,
|
|
.detail-table {
|
|
width: 100%;
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
}
|
|
|
|
.partition-table {
|
|
min-width: 860px;
|
|
}
|
|
|
|
.detail-table {
|
|
min-width: 0;
|
|
}
|
|
|
|
.partition-table th,
|
|
.partition-table td,
|
|
.detail-table th,
|
|
.detail-table td {
|
|
padding: 11px 14px;
|
|
border-bottom: 1px solid var(--monitor-border);
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
font-size: 12.5px;
|
|
line-height: 1.45;
|
|
color: var(--monitor-text);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.partition-table th,
|
|
.detail-table th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
background: color-mix(in srgb, var(--monitor-surface) 86%, var(--monitor-panel-bg));
|
|
color: var(--monitor-muted);
|
|
font-size: 12px;
|
|
font-weight: 680;
|
|
}
|
|
|
|
.partition-table tbody tr:last-child td,
|
|
.detail-table tbody tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.partition-table tbody tr {
|
|
transition:
|
|
background 0.18s ease,
|
|
color 0.18s ease;
|
|
}
|
|
|
|
.partition-table.interactive-table tbody tr {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.partition-table tbody tr:hover,
|
|
.partition-table tbody tr.selected {
|
|
background: var(--monitor-hover);
|
|
}
|
|
|
|
.partition-table tbody tr:nth-child(even):not(.selected):not(.warning) {
|
|
background: color-mix(in srgb, var(--monitor-surface) 90%, var(--monitor-panel-bg));
|
|
}
|
|
|
|
.partition-table tbody tr.warning {
|
|
background: var(--monitor-warning-soft);
|
|
}
|
|
|
|
.table-status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 24px;
|
|
padding: 3px 9px;
|
|
border-radius: 7px;
|
|
font-size: 12px;
|
|
font-weight: 650;
|
|
line-height: 1;
|
|
}
|
|
|
|
.table-status.healthy {
|
|
color: var(--monitor-success);
|
|
background: var(--monitor-success-soft);
|
|
}
|
|
|
|
.table-status.warning {
|
|
color: var(--monitor-warning);
|
|
background: var(--monitor-warning-soft);
|
|
}
|
|
|
|
.partition-detail {
|
|
padding: 14px;
|
|
border: 1px solid var(--monitor-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: var(--monitor-surface);
|
|
box-shadow: var(--monitor-shadow);
|
|
}
|
|
|
|
.partition-detail h5 {
|
|
margin: 0 0 12px;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
font-weight: 680;
|
|
color: var(--monitor-text);
|
|
}
|
|
|
|
.detail-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.detail-title {
|
|
margin-bottom: 8px;
|
|
color: var(--monitor-muted);
|
|
font-size: 12px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.empty-state.compact {
|
|
padding: 16px;
|
|
border: 1px dashed var(--monitor-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
background: color-mix(in srgb, var(--monitor-surface) 74%, transparent);
|
|
}
|
|
|
|
.health-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 13px 15px;
|
|
background: var(--monitor-surface);
|
|
border: 1px solid var(--monitor-border);
|
|
border-radius: var(--dbx-radius-fixed-6);
|
|
box-shadow: 0 1px 0 rgb(255 255 255 / 0.48) inset;
|
|
}
|
|
|
|
.health-label {
|
|
font-size: 13px;
|
|
line-height: 1.35;
|
|
color: var(--monitor-muted);
|
|
font-weight: 560;
|
|
}
|
|
|
|
.health-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 24px;
|
|
padding: 4px 10px;
|
|
border-radius: 7px;
|
|
font-size: 12px;
|
|
font-weight: 650;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.health-badge.healthy {
|
|
background: var(--monitor-success-soft);
|
|
color: var(--monitor-success);
|
|
}
|
|
|
|
.health-badge.warning {
|
|
background: var(--monitor-warning-soft);
|
|
color: var(--monitor-warning);
|
|
}
|
|
|
|
.health-badge.idle {
|
|
background: color-mix(in srgb, var(--monitor-muted) 12%, transparent);
|
|
color: var(--monitor-muted);
|
|
}
|
|
|
|
.health-badge.unavailable {
|
|
background: color-mix(in srgb, var(--monitor-muted) 8%, transparent);
|
|
color: var(--monitor-muted);
|
|
}
|
|
|
|
.monitoring-message-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
@keyframes monitor-spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
@keyframes monitor-skeleton {
|
|
0% {
|
|
background-position: 120% 0;
|
|
}
|
|
100% {
|
|
background-position: -120% 0;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.panel-toolbar {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.toolbar-actions {
|
|
justify-content: flex-start;
|
|
width: 100%;
|
|
}
|
|
|
|
.stats-container {
|
|
padding: 18px 16px 26px;
|
|
}
|
|
|
|
.loading-skeleton-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.stats-grid,
|
|
.charts-grid,
|
|
.health-indicators,
|
|
.loading-skeleton-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.stat-card {
|
|
min-height: 84px;
|
|
}
|
|
}
|
|
</style>
|