Files
dbx/apps/desktop/src/components/mq/MonitoringPanel.vue
T

1623 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { formatError } from "@/lib/backend/errorUtils";
import { computed, ref, watch, onMounted, onUnmounted } from "vue";
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 { TopicRef, TopicInfo, TopicStats, BacklogStats, PeekedMessage } from "@/types/mq";
import { mqGetTopicStats, mqGetBacklog, mqPeekMessages } from "@/lib/backend/api";
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]);
interface Props {
connectionId: string;
topic?: TopicInfo;
tenant?: string;
namespace?: string;
}
interface MetricPoint {
time: string;
msgRateIn: number;
msgRateOut: number;
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 KafkaPartitionStatsRow {
partition: number;
beginOffset: number;
endOffset: number;
messageCount: number;
leader: number;
replicas: number[];
isr: number[];
}
const props = defineProps<Props>();
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>();
const kafkaMessageSql = ref("");
const kafkaMessageLoading = ref(false);
const kafkaMessageError = ref<string>();
const kafkaMessages = ref<PeekedMessage[]>([]);
let refreshTimer: number | undefined;
const history = ref<MetricPoint[]>([]);
const MAX_HISTORY_POINTS = 60;
const partitionRows = computed(() => extractPartitionRows(stats.value?.raw));
const kafkaPartitionRows = computed(() => extractKafkaPartitionRows(stats.value?.raw));
const isKafkaStats = computed(() => isKafkaStatsPayload(stats.value?.raw));
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: ["In", "Out"] },
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: "msg/s" },
series: [
{ name: "In", type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgRateIn) },
{ name: "Out", type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgRateOut) },
],
}));
const backlogChartOption = computed(() => ({
tooltip: { trigger: "axis" },
legend: { top: 0, data: ["Messages", "Bytes"] },
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: "msg" },
{ type: "value", name: "bytes" },
],
series: [
{ name: "Messages", type: "line", smooth: true, showSymbol: false, data: history.value.map((point) => point.msgBacklog) },
{ name: "Bytes", 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: ["Consumer lag"] },
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: "ms" },
series: [{ name: "Consumer lag", 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.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 (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 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(),
msgRateIn: statsData.msgRateIn,
msgRateOut: 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 extractKafkaPartitionRows(raw: unknown): KafkaPartitionStatsRow[] {
return arrayObjects(objectRecord(raw).partitionStats)
.map((body) => ({
partition: numberField(body.partition) ?? 0,
beginOffset: numberField(body.beginOffset) ?? 0,
endOffset: numberField(body.endOffset) ?? 0,
messageCount: numberField(body.messageCount) ?? 0,
leader: numberField(body.leader) ?? -1,
replicas: numberArrayField(body.replicas),
isr: numberArrayField(body.isr),
}))
.sort((a, b) => a.partition - b.partition);
}
function isKafkaStatsPayload(raw: unknown): boolean {
const root = objectRecord(raw);
return Array.isArray(root.partitionStats) || (numberField(root.partitions) !== undefined && numberField(root.replicationFactor) !== undefined && numberField(root.totalMessages) !== undefined);
}
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 "无 leader";
if (row.replicas.length > 0 && row.isr.length < row.replicas.length) return "ISR 不完整";
return "正常";
}
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 numberArrayField(value: unknown): number[] {
return Array.isArray(value) ? value.map(numberField).filter((item): item is number => item !== 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();
}
watch(
() => props.topic,
() => {
history.value = [];
selectedPartitionName.value = undefined;
kafkaMessageSql.value = defaultKafkaMessageSql();
kafkaMessageError.value = undefined;
kafkaMessages.value = [];
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>监控统计</h3>
<div class="toolbar-actions">
<label class="checkbox-label">
<input type="checkbox" v-model="autoRefresh" />
<span>自动刷新</span>
</label>
<select v-model.number="refreshInterval" :disabled="!autoRefresh" class="refresh-interval">
<option :value="5">5秒</option>
<option :value="10">10秒</option>
<option :value="30">30秒</option>
<option :value="60">60秒</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 ? "刷新中..." : "立即刷新" }}</span>
</button>
</div>
</div>
<div v-if="!topic" class="panel-placeholder">
<Table2 :size="24" />
<span>请先选择一个主题</span>
</div>
<div v-else-if="error" class="panel-error">
<AlertTriangle :size="18" />
<span>{{ error }}</span>
</div>
<div v-else-if="loading && !stats" class="panel-loading">
<Loader2 class="loading-icon spinning" :size="22" />
<span>加载监控数据...</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 && isKafkaStats" class="stats-container">
<div class="stats-section">
<h4>Kafka Topic 概览</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">分区数</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">副本因子</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">消息数</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">Log end offset</div>
<div class="stat-value">{{ formatNumber(kafkaOverview.totalEndOffset) }}</div>
</div>
</div>
</div>
</div>
<div class="stats-section">
<h4>Offset 与副本状态</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">起始 offset</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">Leader 数</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">ISR 健康分区</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">无 leader 分区</div>
<div class="stat-value">{{ kafkaOverview.offlinePartitions }}</div>
</div>
</div>
</div>
</div>
<div class="stats-section">
<h4>Kafka 分区明细</h4>
<div v-if="kafkaPartitionRows.length" class="partition-layout">
<div class="partition-table-wrap">
<table class="partition-table">
<thead>
<tr>
<th>分区</th>
<th>起始 offset</th>
<th>Log end offset</th>
<th>消息数</th>
<th>Leader</th>
<th>Replicas</th>
<th>ISR</th>
<th>状态</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">当前 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">
<!-- Overview Section -->
<div class="stats-section">
<h4>消息速率</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">入站速率</div>
<div class="stat-value">{{ stats.msgRateIn.toFixed(2) }} msg/s</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><Upload :size="21" /></div>
<div class="stat-content">
<div class="stat-label">出站速率</div>
<div class="stat-value">{{ stats.msgRateOut.toFixed(2) }} msg/s</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"><Activity :size="21" /></div>
<div class="stat-content">
<div class="stat-label">入站吞吐量</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">出站吞吐量</div>
<div class="stat-value">{{ formatBytes(stats.msgThroughputOut) }}/s</div>
</div>
</div>
</div>
</div>
<div class="charts-grid">
<div class="chart-panel">
<h4>速率趋势</h4>
<VChart :option="rateChartOption" autoresize class="trend-chart" />
</div>
<div class="chart-panel">
<h4>积压趋势</h4>
<VChart :option="backlogChartOption" autoresize class="trend-chart" />
</div>
<div class="chart-panel">
<h4>消费延迟</h4>
<VChart :option="latencyChartOption" autoresize class="trend-chart" />
</div>
</div>
<!-- Storage Section -->
<div class="stats-section">
<h4>存储与积压</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">存储大小</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">积压大小</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">积压消息数</div>
<div class="stat-value">{{ formatNumber(backlog.msgBacklog) }}</div>
</div>
</div>
</div>
</div>
<!-- Counters Section -->
<div class="stats-section">
<h4>消息计数器</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">已发布消息</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">已消费消息</div>
<div class="stat-value">{{ formatNumber(stats.msgOutCounter) }}</div>
</div>
</div>
</div>
</div>
<!-- Connections Section -->
<div class="stats-section">
<h4>连接统计</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">订阅数量</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">生产者数量</div>
<div class="stat-value">{{ stats.producerCount }}</div>
</div>
</div>
</div>
</div>
<div class="stats-section">
<h4>分区明细</h4>
<div v-if="partitionRows.length" class="partition-layout">
<div class="partition-table-wrap">
<table class="partition-table interactive-table">
<thead>
<tr>
<th>分区</th>
<th>入站</th>
<th>出站</th>
<th>入站吞吐</th>
<th>出站吞吐</th>
<th>积压消息</th>
<th>积压大小</th>
<th>生产者</th>
<th>订阅</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">生产者</div>
<table v-if="selectedPartitionPublishers.length" class="detail-table">
<thead>
<tr>
<th>名称</th>
<th>速率</th>
<th>地址</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">暂无生产者</div>
</div>
<div>
<div class="detail-title">订阅</div>
<table v-if="selectedPartitionSubscriptions.length" class="detail-table">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>积压</th>
<th>消费者</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">暂无订阅</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state compact">
{{ topic.partitioned ? "当前 Broker 响应未返回分区指标" : "非分区主题没有分区明细" }}
</div>
</div>
<!-- Health Indicators -->
<div class="stats-section">
<h4>健康指标</h4>
<div class="health-indicators">
<div class="health-item">
<span class="health-label">消息流动:</span>
<span :class="['health-badge', stats.msgRateIn > 0 || stats.msgRateOut > 0 ? 'healthy' : 'idle']">
{{ stats.msgRateIn > 0 || stats.msgRateOut > 0 ? "活跃" : "空闲" }}
</span>
</div>
<div class="health-item">
<span class="health-label">积压状态:</span>
<span :class="['health-badge', stats.backlogSize < 10 * 1024 * 1024 ? 'healthy' : 'warning']">
{{ stats.backlogSize < 10 * 1024 * 1024 ? "正常" : "偏高" }}
</span>
</div>
<div class="health-item">
<span class="health-label">生产者:</span>
<span :class="['health-badge', stats.producerCount > 0 ? 'healthy' : 'idle']">
{{ stats.producerCount > 0 ? "已连接" : "无连接" }}
</span>
</div>
<div class="health-item">
<span class="health-label">订阅:</span>
<span :class="['health-badge', stats.subscriptionCount > 0 ? 'healthy' : 'idle']">
{{ stats.subscriptionCount > 0 ? "活跃" : "无订阅" }}
</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: #0f766e;
--monitor-accent-soft: rgb(15 118 110 / 0.1);
--monitor-accent-border: rgb(15 118 110 / 0.22);
--monitor-success: #15803d;
--monitor-success-soft: rgb(21 128 61 / 0.1);
--monitor-warning: #b45309;
--monitor-warning-soft: rgb(180 83 9 / 0.12);
--monitor-danger: #b91c1c;
--monitor-danger-soft: rgb(185 28 28 / 0.1);
--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%, #0f766e 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%, #0f766e 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%, #2dd4bf 24%);
--monitor-accent: #2dd4bf;
--monitor-accent-soft: rgb(45 212 191 / 0.12);
--monitor-accent-border: rgb(45 212 191 / 0.24);
--monitor-success: #4ade80;
--monitor-success-soft: rgb(74 222 128 / 0.12);
--monitor-warning: #f59e0b;
--monitor-warning-soft: rgb(245 158 11 / 0.14);
--monitor-danger: #f87171;
--monitor-danger-soft: rgb(248 113 113 / 0.12);
--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: color-mix(in srgb, var(--monitor-surface) 92%, transparent);
backdrop-filter: blur(10px);
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: 8px;
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: 8px;
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: 8px;
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);
transform: translateY(-1px);
}
.btn-sm:active:not(:disabled) {
transform: translateY(0) scale(0.98);
}
.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: 8px;
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;
}
.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));
gap: 14px;
margin-bottom: 28px;
}
.chart-panel {
min-height: 260px;
padding: 14px;
border: 1px solid var(--monitor-border);
border-radius: 8px;
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: 8px;
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: 8px;
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: 8px;
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: 8px;
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: 8px;
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: 8px;
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);
}
@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>