Files
ctms/frontend/src/components/PermissionMonitoring.vue
T

1930 lines
45 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.
<template>
<div class="permission-monitoring" :class="{ 'is-source-tab': activeTab === 'ip-locations' }">
<el-tabs v-model="activeTab" class="soybean-monitoring-tabs" @tab-change="onTabChange">
<el-tab-pane label="运行概览" name="overview">
<template #label>
<span class="soybean-tab-label">
<component :is="IconCheck" />
<span>运行概览</span>
</span>
</template>
<div class="overview-pane">
<section class="overview-hero-card">
<div class="overview-title-group">
<h2>运行概览</h2>
</div>
<div v-if="statsSummary" class="metric-strip" aria-label="运行指标">
<div v-for="card in summaryCards" :key="card.label" class="metric-strip-item" :class="card.tone">
<div class="metric-strip-icon">
<component :is="card.icon" />
</div>
<div class="metric-strip-content">
<span class="metric-strip-label">{{ card.label }}</span>
<strong class="metric-strip-value">{{ card.value }}</strong>
<small v-if="card.detail" class="metric-strip-detail">{{ card.detail }}</small>
</div>
</div>
</div>
<div class="overview-hero-actions">
<div class="overview-actions-row">
<TimeRangeSelector v-model="timeRangePreset" @change="loadOverviewData" />
<el-button size="small" :loading="overviewLoading" @click="loadOverviewData">刷新</el-button>
</div>
<div class="overview-meta-row">
<span v-if="lastUpdatedAt" class="ctms-updated-at" :title="`更新于 ${formatUpdatedAt(lastUpdatedAt)}`">
更新 {{ formatUpdatedTime(lastUpdatedAt) }}
</span>
</div>
</div>
</section>
<div v-if="overviewError" class="overview-alert-row">
<el-alert
:title="overviewError"
type="warning"
show-icon
:closable="false"
/>
</div>
<section v-if="health || metrics || alerts || alertsError" class="operations-card">
<div class="operations-card-header">
<div>
<h3>运行状态</h3>
<span> 1 小时运行信号</span>
</div>
<el-tag
v-if="health"
:type="getHealthType(healthDisplayStatus)"
:effect="health.sample_sufficient ? 'dark' : 'plain'"
round
>
{{ health.sample_sufficient ? getHealthLabel(health.status) : "待评估" }}
</el-tag>
</div>
<div class="operations-grid">
<section v-if="health" class="status-section health-section">
<div class="section-heading">系统健康</div>
<div class="health-summary">
<el-progress
type="dashboard"
:percentage="health.sample_sufficient ? health.health_score : 0"
:color="health.sample_sufficient ? getScoreColor(health.health_score) : '#cbd5e1'"
:width="96"
:stroke-width="8"
>
<template #default>
<div class="score-inner">
<strong>{{ health.sample_sufficient ? health.health_score : "--" }}</strong>
<span>{{ health.sample_sufficient ? "健康分" : "待评估" }}</span>
</div>
</template>
</el-progress>
<div class="health-summary-copy" :class="`is-${healthDisplayStatus}`">
<span class="health-summary-label">综合状态</span>
<strong>{{ health.sample_sufficient ? getHealthSummaryLabel(health.status) : "待评估" }}</strong>
</div>
</div>
<div v-if="health.sample_sufficient" class="health-signal-list">
<div v-for="item in healthScoreItems" :key="item.label" class="health-signal-item" :class="{ 'is-warning': item.deduction }">
<span>{{ item.label }}</span>
<strong>{{ item.deduction ? `-${item.deduction}` : "正常" }}</strong>
</div>
</div>
<div v-else class="sample-warning">
1 小时仅 {{ health.last_hour.total_checks }} 次权限检查业务样本不足
</div>
<div v-if="health.issues.length > 0" class="health-issues">
<div v-for="(issue, index) in health.issues" :key="index" class="issue-item">
<span class="issue-dot" />
<span>{{ issue }}</span>
</div>
</div>
</section>
<section v-if="metrics" class="status-section cache-section">
<div class="section-heading">
<span>缓存状态</span>
<small> 1 小时</small>
</div>
<div class="cache-stat primary">
<div class="cache-stat-header">
<span class="stat-label">
综合命中率{{ metrics.cache_metrics.cache_hits }}/{{ metrics.cache_metrics.total_accesses }}
</span>
<strong class="stat-value" :style="{ color: cacheRateColor }">
{{ hasCacheSamples ? `${metrics.cache_metrics.hit_rate.toFixed(1)}%` : "--" }}
</strong>
</div>
<el-progress
:percentage="hasCacheSamples ? metrics.cache_metrics.hit_rate : 0"
:color="cacheRateColor"
:stroke-width="8"
:show-text="false"
/>
<small v-if="!hasCacheSamples" class="no-sample-hint">暂无缓存访问样本</small>
</div>
<div class="cache-scope-details">
<span>
项目权限
{{ formatSampleRate(metrics.cache_metrics.by_cache.project_permissions.total_accesses, metrics.cache_metrics.by_cache.project_permissions.hit_rate) }}
{{ metrics.cache_metrics.by_cache.project_permissions.cache_hits }}/{{
metrics.cache_metrics.by_cache.project_permissions.total_accesses
}}
</span>
<span>
成员角色
{{ formatSampleRate(metrics.cache_metrics.by_cache.member_roles.total_accesses, metrics.cache_metrics.by_cache.member_roles.hit_rate) }}
{{ metrics.cache_metrics.by_cache.member_roles.cache_hits }}/{{
metrics.cache_metrics.by_cache.member_roles.total_accesses
}}
</span>
</div>
<div class="cache-stat-row">
<div class="cache-stat-mini">
<span class="stat-label">缓存项目</span>
<strong>{{ health?.cache_stats?.cache_items.total_count || 0 }}</strong>
</div>
<div class="cache-stat-mini">
<span class="stat-label">失效次数</span>
<strong>{{ metrics.cache_metrics.cache_invalidations }}</strong>
</div>
</div>
</section>
<section v-if="runtimeComponentItems.length" class="status-section runtime-section">
<div class="section-subtitle">组件状态</div>
<div class="runtime-components">
<div v-for="component in runtimeComponentItems" :key="component.label" class="runtime-component">
<div>
<strong>{{ component.label }}</strong>
<small>{{ component.detail }}</small>
</div>
<el-tag :type="getHealthType(component.status)" size="small" effect="plain">
{{ getComponentStatusLabel(component.status) }}
</el-tag>
</div>
</div>
<div v-if="health?.storage" class="storage-summary">
<span>监测日志 <strong>{{ monitoringLogCount.toLocaleString() }}</strong></span>
<span>指标快照 <strong>{{ health.storage.permission_metric_snapshots.toLocaleString() }}</strong></span>
</div>
</section>
</div>
<section class="alerts-section">
<div class="section-heading">
<span>最近告警</span>
<el-tag v-if="alerts && alerts.alerts.length > 0" effect="dark" size="small" type="warning" round>
{{ alerts.alerts.length }}
</el-tag>
</div>
<el-alert v-if="alertsError" :title="alertsError" type="warning" show-icon :closable="false" />
<div v-else-if="alerts && alerts.alerts.length > 0" class="alerts-list">
<div v-for="(alert, index) in alerts.alerts" :key="index" class="alert-row" :class="alert.level">
<div class="alert-level">
<el-tag :type="getAlertType(alert.level)" size="small" effect="dark" round>{{ alert.level }}</el-tag>
</div>
<div class="alert-body">
<strong>{{ alert.type }}</strong>
<span>{{ alert.message }}</span>
</div>
<span class="alert-time">{{ formatTime(alert.timestamp) }}</span>
</div>
</div>
<div v-else-if="alerts" class="empty-alert-state">
<span class="empty-alert-icon"></span>
<div>
<strong>暂无活动告警</strong>
</div>
</div>
<el-empty v-else description="告警数据尚未加载" :image-size="44" />
</section>
</section>
<div v-if="topDenied && topDenied.length > 0" class="denied-card">
<div class="card-header">
<h3>异常拒绝排行</h3>
<el-tag effect="plain" size="small" type="info">{{ currentDeniedPeriodLabel }}</el-tag>
</div>
<div class="denied-list">
<div v-for="(item, index) in topDenied" :key="index" class="denied-row">
<span class="denied-rank" :class="{ top: index < 3 }">{{ index + 1 }}</span>
<div class="denied-info">
<strong>{{ item.endpoint_key }}</strong>
<small>{{ item.role }}</small>
</div>
<span class="denied-count">{{ item.denied_count }}</span>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="运行趋势" name="trends" lazy>
<template #label>
<span class="soybean-tab-label">
<component :is="IconActivity" />
<span>运行趋势</span>
</span>
</template>
<PermissionTrendCharts ref="trendChartsRef" @drilldown="openTrendDrilldown" />
</el-tab-pane>
<el-tab-pane v-if="props.isAdmin" label="安全中心" name="security" lazy>
<template #label>
<span class="soybean-tab-label">
<component :is="IconShield" />
<span>安全中心</span>
</span>
</template>
<SecurityCenter v-if="props.isAdmin" ref="securityCenterRef" />
</el-tab-pane>
<el-tab-pane label="访问日志" name="logs" lazy>
<template #label>
<span class="soybean-tab-label">
<component :is="IconDatabase" />
<span>访问日志</span>
</span>
</template>
<PermissionAccessLogs ref="accessLogsRef" />
</el-tab-pane>
<el-tab-pane label="来源分析" name="ip-locations" lazy>
<template #label>
<span class="soybean-tab-label">
<component :is="IconPercent" />
<span>来源分析</span>
</span>
</template>
<div class="ip-locations-pane">
<PermissionIpLocations ref="ipLocationsRef" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h, nextTick, onMounted, onUnmounted } from "vue";
import type {
PermissionMetricsResponse,
AlertsResponse,
HealthResponse,
TopDeniedItem,
StatsSummaryResponse,
} from "@/types/api";
import {
fetchPermissionMetrics,
fetchPermissionAlerts,
fetchPermissionHealth,
fetchTopDenied,
fetchStatsSummary,
} from "@/api/projectPermissions";
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
import PermissionIpLocations from "./PermissionIpLocations.vue";
import SecurityCenter from "./SecurityCenter.vue";
import TimeRangeSelector from "./TimeRangeSelector.vue";
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("polyline", { points: "20 6 9 17 4 12" }),
]);
const IconActivity = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("polyline", { points: "22 12 18 12 15 21 9 3 6 12 2 12" }),
]);
const IconPercent = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("line", { x1: "19", y1: "5", x2: "5", y2: "19" }),
h("circle", { cx: "6.5", cy: "6.5", r: "2.5" }),
h("circle", { cx: "17.5", cy: "17.5", r: "2.5" }),
]);
const IconClock = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("polyline", { points: "12 6 12 12 16 14" }),
]);
const IconDatabase = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }),
h("path", { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }),
h("path", { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }),
]);
const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
]);
const activeTab = ref("overview");
const props = withDefaults(defineProps<{ isAdmin?: boolean }>(), {
isAdmin: false,
});
const metrics = ref<PermissionMetricsResponse | null>(null);
const health = ref<HealthResponse | null>(null);
const alerts = ref<AlertsResponse | null>(null);
const topDenied = ref<TopDeniedItem[]>([]);
const statsSummary = ref<StatsSummaryResponse | null>(null);
const overviewLoading = ref(false);
const overviewError = ref("");
const alertsError = ref("");
const lastUpdatedAt = ref<Date | null>(null);
type TimeRangePreset = "all" | "24h" | "7d" | "30d";
const timeRangePreset = ref<TimeRangePreset>("7d");
const timeRangeOptions = [
{ value: "all", label: "全部" },
{ value: "24h", label: "24小时" },
{ value: "7d", label: "7天" },
{ value: "30d", label: "30天" },
] as const;
const getTopDeniedDays = (preset: TimeRangePreset): number => {
const map: Record<TimeRangePreset, number> = {
all: 90,
"24h": 1,
"7d": 7,
"30d": 30,
};
return map[preset];
};
const currentDeniedPeriodLabel = computed(() => {
const map: Record<TimeRangePreset, string> = {
all: "近 90 天",
"24h": "近 24 小时",
"7d": "近 7 天",
"30d": "近 30 天",
};
return map[timeRangePreset.value];
});
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
type IpLocationsExpose = {
refresh: () => void | Promise<void>;
resize: () => void | Promise<void>;
};
const ipLocationsRef = ref<IpLocationsExpose>();
interface TrendDrilldownPayload {
preset?: "24h" | "7d" | "30d";
startAt?: string;
endAt?: string;
allowed?: boolean;
role?: string;
clientType?: string;
keyword?: string;
}
const openTrendDrilldown = async (payload: TrendDrilldownPayload) => {
activeTab.value = "logs";
await nextTick();
await accessLogsRef.value?.applyTrendFilter(payload);
};
const securityCenterRef = ref<InstanceType<typeof SecurityCenter>>();
const formatTime = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleString("zh-CN");
};
const formatUpdatedAt = (value: Date): string => value.toLocaleString("zh-CN", { hour12: false });
const formatUpdatedTime = (value: Date): string => value.toLocaleTimeString("zh-CN", { hour12: false });
const getHealthType = (status: string) => {
const map: Record<string, string> = { healthy: "success", degraded: "warning", unhealthy: "danger" };
return map[status] || "info";
};
const getHealthLabel = (status: string) => {
const map: Record<string, string> = { healthy: "健康", degraded: "降级", unhealthy: "不健康" };
return map[status] || status;
};
const getHealthSummaryLabel = (status: string) => {
const map: Record<string, string> = { healthy: "正常", degraded: "需关注", unhealthy: "异常" };
return map[status] || "待评估";
};
const getComponentStatusLabel = (status: string) => {
const map: Record<string, string> = { healthy: "正常", degraded: "降级", unhealthy: "异常", unknown: "未知" };
return map[status] || status;
};
const getAlertType = (level: string) => {
const map: Record<string, string> = { info: "info", warning: "warning", error: "danger" };
return map[level] || "info";
};
const getScoreColor = (score: number): string => {
if (score >= 80) return "#10b981";
if (score >= 60) return "#f59e0b";
return "#ef4444";
};
const formatResponseTime = (elapsedMs: number): string => {
if (elapsedMs <= 0) return "<0.1ms";
if (elapsedMs < 10) return `${elapsedMs.toFixed(1)}ms`;
return `${Math.round(elapsedMs)}ms`;
};
const summaryCards = computed(() => {
if (!statsSummary.value) return [];
const hasTodaySamples = statsSummary.value.today.total_checks > 0;
return [
{
label: "今日监测事件",
value: statsSummary.value.today.total_checks.toLocaleString(),
detail: `历史 ${statsSummary.value.total_logs.toLocaleString()}`,
tone: "blue",
icon: IconCheck,
},
{
label: "每分钟事件",
value: statsSummary.value.last_hour.requests_per_minute,
detail: "近 1 小时",
tone: "cyan",
icon: IconActivity,
},
{
label: "今日通过率",
value: hasTodaySamples ? `${statsSummary.value.today.allow_rate}%` : "--",
detail: hasTodaySamples ? "今日监测" : "暂无样本",
tone: hasTodaySamples
? statsSummary.value.today.allow_rate >= 80 ? "green" : "warning"
: "neutral",
icon: IconPercent,
},
{
label: "平均响应时间",
value: hasTodaySamples ? formatResponseTime(statsSummary.value.today.avg_elapsed_ms) : "--",
detail: hasTodaySamples ? "今日平均值" : "暂无样本",
tone: hasTodaySamples
? statsSummary.value.today.avg_elapsed_ms > 10 ? "danger" : "indigo"
: "neutral",
icon: IconClock,
},
];
});
const healthDisplayStatus = computed(() =>
health.value?.sample_sufficient ? health.value.status : "unknown"
);
const hasCacheSamples = computed(() =>
Boolean(metrics.value && metrics.value.cache_metrics.total_accesses > 0)
);
const cacheRateColor = computed(() => {
if (!hasCacheSamples.value) return "#cbd5e1";
const hitRate = metrics.value?.cache_metrics.hit_rate || 0;
if (hitRate >= 80) return "#10b981";
if (hitRate >= 50) return "#f59e0b";
return "#ef4444";
});
const formatSampleRate = (total: number, rate: number) => total > 0 ? `${rate.toFixed(1)}%` : "--";
const monitoringLogCount = computed(() => {
const storage = health.value?.storage;
if (!storage) return 0;
return storage.permission_access_logs + storage.security_access_logs;
});
const healthScoreItems = computed(() => {
if (!health.value) return [];
return [
{ label: "性能", deduction: health.value.score_breakdown.performance.deduction },
{ label: "拒绝率", deduction: health.value.score_breakdown.deny_rate.deduction },
{ label: "缓存", deduction: health.value.score_breakdown.cache.deduction },
{ label: "告警", deduction: health.value.score_breakdown.alerts.deduction },
{ label: "运行组件", deduction: health.value.score_breakdown.runtime?.deduction || 0 },
];
});
const runtimeComponentItems = computed(() => {
const components = health.value?.components;
if (!components) return [];
return [
{
label: "数据库",
status: components.database.status,
detail: components.database.latency_ms === undefined ? "延迟未知" : `${components.database.latency_ms.toFixed(1)}ms`,
},
{
label: "权限日志写入",
status: components.permission_log_writer.status,
detail: `队列 ${components.permission_log_writer.queue_size || 0}/${components.permission_log_writer.queue_capacity || 0},丢弃 ${components.permission_log_writer.dropped_entries || 0}`,
},
{
label: "安全日志写入",
status: components.security_log_writer.status,
detail: `队列 ${components.security_log_writer.queue_size || 0}/${components.security_log_writer.queue_capacity || 0},丢弃 ${components.security_log_writer.dropped_entries || 0}`,
},
{
label: "留存清理",
status: components.retention.status,
detail: `访问日志 ${components.retention.access_log_retention_days || "-"} 天,指标 ${components.retention.metric_retention_days || "-"} 天`,
},
];
});
const loadOverviewData = async () => {
if (overviewLoading.value) return;
overviewLoading.value = true;
const [metricsRes, healthRes, alertsRes, deniedRes, summaryRes] = await Promise.allSettled([
fetchPermissionMetrics(),
fetchPermissionHealth(),
fetchPermissionAlerts(undefined, 20),
fetchTopDenied({ days: getTopDeniedDays(timeRangePreset.value), limit: 10 }),
fetchStatsSummary(),
]);
if (metricsRes.status === "fulfilled") metrics.value = metricsRes.value.data;
if (healthRes.status === "fulfilled") health.value = healthRes.value.data;
if (alertsRes.status === "fulfilled") {
alerts.value = alertsRes.value.data;
alertsError.value = "";
} else {
alertsError.value = "告警数据加载失败,不能据此判断系统正常";
}
if (deniedRes.status === "fulfilled") topDenied.value = deniedRes.value.data.items;
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
const results = [metricsRes, healthRes, alertsRes, deniedRes, summaryRes];
const failedCount = results.filter((result) => result.status === "rejected").length;
const successfulCount = results.length - failedCount;
if (successfulCount > 0) lastUpdatedAt.value = new Date();
overviewError.value = failedCount
? `部分监测数据加载失败(${failedCount}/${results.length}),当前页面可能包含上次成功结果`
: "";
overviewLoading.value = false;
};
const onTabChange = async (tab: string) => {
if (tab === "overview") loadOverviewData();
if (tab === "ip-locations") {
await nextTick();
await ipLocationsRef.value?.resize();
}
};
const refresh = () => {
if (activeTab.value === "overview") loadOverviewData();
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
else if (activeTab.value === "security") securityCenterRef.value?.refresh();
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
};
let overviewRefreshTimer: number | undefined;
onMounted(() => {
loadOverviewData();
overviewRefreshTimer = window.setInterval(() => {
if (activeTab.value === "overview" && document.visibilityState === "visible") loadOverviewData();
}, 60_000);
});
onUnmounted(() => {
if (overviewRefreshTimer !== undefined) window.clearInterval(overviewRefreshTimer);
});
defineExpose({ refresh });
</script>
<style scoped>
.permission-monitoring {
--mon-ink: #1a2332;
--mon-muted: #64748b;
--mon-border: #e2e8f0;
--mon-tab-active: #6c63ff;
--mon-tab-active-bg: #f0edff;
--mon-radius: 14px;
--mon-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 6px 18px rgba(15, 23, 42, 0.025);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 0;
}
.soybean-monitoring-tabs {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.soybean-monitoring-tabs :deep(.el-tabs__header) {
position: relative;
z-index: 10;
flex: 0 0 52px;
height: 52px;
margin: 0 0 10px;
padding: 0 24px;
background: #ffffff;
border-top: 1px solid var(--mon-border);
border-bottom: 1px solid var(--mon-border);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.soybean-monitoring-tabs :deep(.el-tabs__nav-wrap) {
height: 52px;
}
.soybean-monitoring-tabs :deep(.el-tabs__nav-wrap::after),
.soybean-monitoring-tabs :deep(.el-tabs__active-bar) {
display: none;
}
.soybean-monitoring-tabs :deep(.el-tabs__nav-scroll),
.soybean-monitoring-tabs :deep(.el-tabs__nav) {
height: 100%;
}
.soybean-monitoring-tabs :deep(.el-tabs__nav) {
display: flex;
align-items: center;
}
.soybean-monitoring-tabs :deep(.el-tabs__item) {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 36px;
margin: 0 4px;
padding: 0 18px;
border-radius: 10px;
color: #303133;
font-size: 15px;
font-weight: 600;
line-height: 1;
transition: color 0.2s ease, background 0.2s ease;
}
:deep(.soybean-monitoring-tabs.el-tabs--top > .el-tabs__header .el-tabs__item:nth-child(2)) {
padding-left: 18px;
}
:deep(.soybean-monitoring-tabs.el-tabs--top > .el-tabs__header .el-tabs__item:last-child) {
padding-right: 18px;
}
.soybean-monitoring-tabs :deep(.el-tabs__item:hover) {
color: var(--mon-tab-active);
}
.soybean-monitoring-tabs :deep(.el-tabs__item.is-active) {
background: var(--mon-tab-active-bg);
color: var(--mon-tab-active);
}
.soybean-monitoring-tabs :deep(.el-tabs__content) {
flex: 1 1 0;
height: auto;
min-height: 0;
padding: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content) {
overflow: hidden;
}
.soybean-monitoring-tabs :deep(.el-tab-pane) {
min-height: 100%;
}
.soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
height: auto;
min-height: 100%;
}
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.ip-locations-pane {
height: 100%;
min-height: 0;
overflow: hidden;
}
.overview-pane {
width: min(100%, 1680px);
margin: 0 auto;
padding: 0 16px 20px;
box-sizing: border-box;
}
.overview-hero-card {
display: flex;
align-items: center;
gap: 20px;
min-height: 88px;
margin: 0 0 14px;
padding: 12px 18px;
border: 1px solid var(--mon-border);
border-radius: var(--mon-radius);
background: #fff;
box-shadow: var(--mon-shadow);
}
.overview-title-group {
flex: 0 0 154px;
min-width: 0;
}
.overview-title-group h2 {
margin: 0;
color: var(--mon-ink);
font-size: 18px;
font-weight: 650;
}
.overview-title-group p {
margin: 3px 0 0;
color: var(--mon-muted);
font-size: 12px;
}
.overview-hero-actions {
display: flex;
flex-direction: column;
flex: 0 0 auto;
align-items: flex-end;
justify-content: center;
gap: 6px;
color: var(--mon-muted);
font-size: 12px;
white-space: nowrap;
}
.overview-actions-row {
display: flex;
align-items: center;
gap: 8px;
}
.overview-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.history-total {
padding: 5px 9px;
border: 1px solid var(--mon-border);
border-radius: 999px;
background: #fff;
}
.history-total strong {
color: var(--mon-ink);
font-weight: 650;
}
.overview-alert-row {
margin: 0 0 14px;
}
.overview-alert-row .el-alert {
flex: 1;
}
.runtime-components {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.runtime-component {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 9px 10px;
border-radius: 10px;
background: #f8fafc;
}
.runtime-component > div {
display: flex;
flex-direction: column;
min-width: 0;
}
.runtime-component strong {
color: var(--mon-ink);
font-size: 12px;
}
.runtime-component small {
overflow: hidden;
color: var(--mon-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.soybean-tab-label {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-width: 0;
line-height: 1;
}
.soybean-tab-label svg {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.metric-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
flex: 1 1 auto;
align-items: center;
min-width: 0;
}
.metric-strip-item {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
padding: 0 14px;
border-left: 1px solid #edf1f6;
}
.metric-strip-item:first-child {
border-left: 0;
}
.metric-strip-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
flex: 0 0 auto;
}
.metric-strip-icon svg {
width: 15px;
height: 15px;
}
.metric-strip-item.blue .metric-strip-icon { background: #eff6ff; color: #3b82f6; }
.metric-strip-item.cyan .metric-strip-icon { background: #ecfeff; color: #06b6d4; }
.metric-strip-item.green .metric-strip-icon { background: #ecfdf5; color: #10b981; }
.metric-strip-item.warning .metric-strip-icon { background: #fffbeb; color: #f59e0b; }
.metric-strip-item.indigo .metric-strip-icon { background: #eef2ff; color: #6366f1; }
.metric-strip-item.danger .metric-strip-icon { background: #fef2f2; color: #ef4444; }
.metric-strip-item.neutral .metric-strip-icon { background: #f1f5f9; color: #94a3b8; }
.metric-strip-content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.metric-strip-label {
overflow: hidden;
color: var(--mon-muted);
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-strip-value {
color: var(--mon-ink);
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.metric-strip-item.danger .metric-strip-value { color: #dc2626; }
.metric-strip-item.warning .metric-strip-value { color: #b45309; }
.overview-grid {
display: grid;
grid-template-columns: minmax(360px, 0.9fr) minmax(0, 1.1fr);
gap: 16px;
margin-bottom: 16px;
}
.cache-card {
grid-column: 1;
grid-row: 1 / span 2;
}
.health-card {
grid-column: 2;
grid-row: 1;
}
.alerts-card {
grid-column: 2;
grid-row: 2;
}
.health-card,
.cache-card,
.denied-card,
.alerts-card {
background: #fff;
border: 1px solid var(--mon-border);
border-radius: var(--mon-radius);
padding: 20px;
box-shadow: var(--mon-shadow);
}
.denied-card,
.alerts-card {
margin-bottom: 16px;
}
.overview-grid .alerts-card {
margin-bottom: 0;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--mon-ink);
}
.health-card h3,
.cache-card h3 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 600;
color: var(--mon-ink);
}
.health-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.health-header h3 {
margin: 0;
}
.health-score-ring {
display: flex;
justify-content: center;
padding: 8px 0 16px;
}
.score-breakdown {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
margin: -6px 0 12px;
}
.score-breakdown span {
padding: 3px 8px;
border-radius: 999px;
background: #f1f5f9;
color: var(--mon-muted);
font-size: 11px;
}
.sample-warning {
margin-bottom: 10px;
padding: 9px 12px;
border-radius: 10px;
background: #fffbeb;
color: #b45309;
font-size: 12px;
text-align: center;
}
.score-inner {
display: flex;
flex-direction: column;
align-items: center;
}
.score-inner strong {
font-size: 32px;
font-weight: 700;
color: var(--mon-ink);
line-height: 1;
}
.score-inner span {
font-size: 12px;
color: var(--mon-muted);
margin-top: 4px;
}
.health-issues {
padding-top: 12px;
border-top: 1px solid var(--mon-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.issue-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #dc2626;
}
.issue-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
flex-shrink: 0;
}
.health-ok {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-radius: 10px;
background: #ecfdf5;
color: #059669;
font-size: 13px;
font-weight: 500;
}
.ok-icon {
font-size: 16px;
font-weight: 700;
}
.cache-stats {
display: flex;
flex-direction: column;
gap: 14px;
}
.cache-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.cache-title h3 {
margin: 0;
}
.cache-title span {
color: var(--mon-muted);
font-size: 12px;
}
.cache-scope-details {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cache-scope-details span {
flex: 1 1 180px;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
color: var(--mon-muted);
font-size: 12px;
}
.cache-stat.primary {
padding: 14px;
background: #f8fafc;
border-radius: 12px;
}
.no-sample-hint {
display: block;
margin-top: 8px;
color: #94a3b8;
font-size: 11px;
}
.runtime-section {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--mon-border);
}
.section-subtitle {
margin-bottom: 9px;
color: var(--mon-muted);
font-size: 12px;
font-weight: 600;
}
.storage-summary {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 13px;
border-top: 1px solid var(--mon-border);
color: var(--mon-muted);
font-size: 12px;
}
.storage-summary strong {
margin-left: 4px;
color: var(--mon-ink);
font-size: 14px;
}
.cache-stat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.cache-stat-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.cache-stat-mini {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background: #f8fafc;
border-radius: 10px;
}
.cache-stat-mini strong {
font-size: 20px;
font-weight: 700;
color: var(--mon-ink);
}
.stat-label {
font-size: 12px;
color: var(--mon-muted);
font-weight: 500;
}
.stat-value {
font-size: 18px;
font-weight: 700;
}
/* Keep the overview focused on operations rather than a stack of oversized cards. */
.overview-pane {
width: 100%;
margin: 0;
padding: 0 12px 12px;
}
.overview-hero-card {
gap: 14px;
min-height: 72px;
margin-bottom: 8px;
padding: 10px 14px;
}
.overview-title-group {
flex-basis: 112px;
}
.overview-title-group h2 {
font-size: 17px;
}
.overview-hero-actions {
flex-direction: row;
align-items: center;
gap: 10px;
}
.overview-actions-row {
gap: 6px;
}
.metric-strip-item {
gap: 8px;
min-height: 44px;
padding: 0 10px;
}
.metric-strip-icon {
width: 24px;
height: 24px;
border-radius: 7px;
}
.metric-strip-icon svg {
width: 14px;
height: 14px;
}
.metric-strip-content {
gap: 1px;
}
.metric-strip-label,
.metric-strip-detail {
font-size: 11px;
}
.metric-strip-detail {
overflow: hidden;
color: var(--mon-muted);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-strip-value {
font-size: 20px;
}
.overview-alert-row {
margin-bottom: 8px;
}
.operations-card,
.denied-card {
margin-bottom: 10px;
padding: 14px;
border: 1px solid var(--mon-border);
border-radius: var(--mon-radius);
background: #fff;
box-shadow: var(--mon-shadow);
}
.operations-card-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 24px;
margin-bottom: 12px;
}
.operations-card-header > div {
display: flex;
align-items: baseline;
gap: 8px;
}
.operations-card-header h3,
.section-heading {
margin: 0;
color: var(--mon-ink);
font-size: 14px;
font-weight: 650;
}
.operations-card-header span,
.section-heading small {
color: var(--mon-muted);
font-size: 11px;
font-weight: 400;
}
.operations-grid {
display: grid;
grid-template-columns: minmax(210px, 0.85fr) minmax(240px, 1fr) minmax(280px, 1.15fr);
gap: 0;
}
.status-section {
min-width: 0;
padding: 0 14px;
border-left: 1px solid var(--mon-border);
}
.status-section:first-child {
padding-left: 0;
border-left: 0;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 20px;
margin-bottom: 8px;
}
.health-summary {
display: flex;
align-items: center;
gap: 12px;
min-height: 96px;
}
.health-summary-copy {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
min-width: 92px;
padding: 9px 11px;
border: 1px solid #bbf7d0;
border-radius: 10px;
background: linear-gradient(145deg, #f0fdf4, #ecfdf5);
}
.health-summary-copy strong {
color: #047857;
font-size: 17px;
line-height: 1.2;
}
.health-summary-copy .health-summary-label {
color: var(--mon-muted);
font-size: 10px;
}
.health-summary-copy.is-degraded {
border-color: #fde68a;
background: linear-gradient(145deg, #fffbeb, #fef3c7);
}
.health-summary-copy.is-degraded strong {
color: #b45309;
}
.health-summary-copy.is-unhealthy {
border-color: #fecaca;
background: linear-gradient(145deg, #fef2f2, #fee2e2);
}
.health-summary-copy.is-unhealthy strong {
color: #dc2626;
}
.health-summary-copy.is-unknown {
border-color: #dbe3ef;
background: #f8fafc;
}
.health-summary-copy.is-unknown strong {
color: #64748b;
}
.score-inner strong {
font-size: 24px;
}
.score-inner span {
margin-top: 2px;
font-size: 11px;
}
.health-signal-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.health-signal-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
border: 1px solid #d1fae5;
border-radius: 999px;
background: #f0fdf4;
font-size: 10px;
white-space: nowrap;
}
.health-signal-item span {
color: var(--mon-muted);
}
.health-signal-item strong {
color: #047857;
font-size: 10px;
}
.health-signal-item.is-warning {
border-color: #fde68a;
background: #fffbeb;
}
.health-signal-item.is-warning strong {
color: #b45309;
}
.sample-warning {
margin: 6px 0 0;
padding: 6px 8px;
border-radius: 8px;
font-size: 11px;
line-height: 1.4;
text-align: left;
}
.health-issues {
margin-top: 8px;
padding-top: 8px;
gap: 5px;
}
.issue-item {
gap: 6px;
font-size: 12px;
}
.cache-stat.primary {
padding: 10px;
border-radius: 10px;
}
.cache-stat-header {
margin-bottom: 6px;
}
.cache-scope-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 6px;
}
.cache-scope-details span {
min-width: 0;
padding: 6px 8px;
font-size: 11px;
line-height: 1.35;
}
.cache-stat-row {
gap: 6px;
margin-top: 6px;
}
.cache-stat-mini {
flex-direction: row;
align-items: baseline;
justify-content: space-between;
gap: 6px;
padding: 7px 8px;
}
.cache-stat-mini strong {
font-size: 15px;
}
.stat-label,
.no-sample-hint {
font-size: 11px;
}
.runtime-section {
margin: 0;
padding-top: 0;
border-top: 0;
}
.section-subtitle {
margin-bottom: 8px;
color: var(--mon-ink);
font-size: 14px;
}
.runtime-components {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.runtime-component {
gap: 6px;
padding: 7px 8px;
border-radius: 8px;
}
.runtime-component strong {
font-size: 11px;
}
.runtime-component small {
font-size: 10px;
}
.storage-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
padding-top: 8px;
font-size: 11px;
}
.storage-summary strong {
display: block;
margin: 2px 0 0;
font-size: 13px;
}
.alerts-section {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--mon-border);
}
.alerts-list {
gap: 6px;
}
.alert-row {
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
}
.empty-alert-state {
justify-content: center;
gap: 9px;
min-height: 62px;
border-radius: 10px;
}
.empty-alert-icon {
width: 24px;
height: 24px;
font-size: 14px;
}
.empty-alert-state strong,
.alert-body strong {
font-size: 12px;
}
.empty-alert-state small,
.alert-body span,
.alert-time {
font-size: 11px;
}
.alerts-section :deep(.el-empty) {
padding: 4px 0;
}
.denied-card .card-header {
margin-bottom: 8px;
}
.denied-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.denied-row {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
border-radius: 10px;
transition: background 0.15s ease;
}
.denied-row:hover {
background: #f8fafc;
}
.denied-rank {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.denied-rank.top {
background: linear-gradient(135deg, #ef4444, #f87171);
color: #fff;
}
.denied-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.denied-info strong {
font-size: 13px;
font-weight: 600;
color: var(--mon-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.denied-info small {
font-size: 11px;
color: var(--mon-muted);
}
.denied-count {
font-size: 16px;
font-weight: 700;
color: #ef4444;
}
.alerts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.alert-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--mon-border);
transition: background 0.15s ease;
}
.alert-row:hover {
background: #f8fafc;
}
.alert-row.error {
border-color: #fecaca;
background: #fef2f2;
}
.alert-row.warning {
border-color: #fed7aa;
background: #fffbeb;
}
.alert-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.alert-body strong {
font-size: 13px;
font-weight: 600;
color: var(--mon-ink);
}
.alert-body span {
font-size: 12px;
color: var(--mon-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alert-time {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
}
.empty-alert-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 92px;
border: 1px dashed #bbf7d0;
border-radius: 12px;
background: #f0fdf4;
color: #166534;
}
.empty-alert-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: #dcfce7;
font-size: 16px;
font-weight: 700;
}
.empty-alert-state div {
display: flex;
flex-direction: column;
gap: 3px;
}
.empty-alert-state strong {
font-size: 13px;
}
.empty-alert-state small {
color: #4d7c5f;
font-size: 12px;
}
/* These states sit at the end of the cascade so alert rows stay compact too. */
.alerts-section {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--mon-border);
}
.alerts-list {
gap: 6px;
}
.alert-row {
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
}
.empty-alert-state {
gap: 9px;
min-height: 62px;
border-radius: 10px;
}
.empty-alert-icon {
width: 24px;
height: 24px;
font-size: 14px;
}
.empty-alert-state strong,
.alert-body strong {
font-size: 12px;
}
.empty-alert-state small,
.alert-body span,
.alert-time {
font-size: 11px;
}
@media (max-width: 1200px) {
.soybean-monitoring-tabs :deep(.el-tabs__header),
.soybean-monitoring-tabs :deep(.el-tabs__nav-wrap) {
height: 48px;
}
.soybean-monitoring-tabs :deep(.el-tabs__header) {
flex-basis: 48px;
margin-bottom: 8px;
padding: 0 12px;
}
.soybean-monitoring-tabs :deep(.el-tabs__item) {
height: 34px;
padding: 0 14px;
}
.operations-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.runtime-section {
grid-column: 1 / -1;
margin-top: 10px;
padding: 10px 0 0;
border-top: 1px solid var(--mon-border);
border-left: 0;
}
}
@media (max-width: 900px) {
.overview-hero-card {
align-items: flex-start;
flex-wrap: wrap;
}
.overview-title-group {
flex: 1 1 auto;
}
.overview-hero-actions {
flex: 0 1 auto;
justify-content: flex-end;
}
.metric-strip {
order: 3;
flex-basis: 100%;
padding-top: 6px;
border-top: 1px solid #edf1f6;
}
}
@media (max-width: 720px) {
.overview-pane {
padding: 0 8px 10px;
}
.metric-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-strip-item:nth-child(odd) {
border-left: 0;
}
.operations-grid {
grid-template-columns: 1fr;
}
.status-section,
.status-section:first-child {
margin-top: 10px;
padding: 10px 0 0;
border-top: 1px solid var(--mon-border);
border-left: 0;
}
.status-section:first-child {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.runtime-section {
grid-column: auto;
}
}
@media (max-width: 520px) {
.overview-hero-actions {
width: 100%;
justify-content: space-between;
}
.metric-strip {
grid-template-columns: 1fr;
}
.metric-strip-item {
border-top: 1px solid #edf1f6;
border-left: 0;
}
.metric-strip-item:first-child {
border-top: 0;
}
.operations-card-header > div {
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.runtime-components,
.cache-scope-details {
grid-template-columns: 1fr;
}
}
</style>