1930 lines
45 KiB
Vue
1930 lines
45 KiB
Vue
<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>
|