Files
ctms/frontend/src/components/PermissionAccessLogs.vue
T
2026-05-28 10:48:33 +08:00

816 lines
23 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="access-logs">
<section class="audit-filters">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
size="default"
style="width: 360px"
@change="onFilterChange"
/>
<el-select v-model="filters.role" placeholder="角色" clearable style="width: 130px" @change="onFilterChange">
<el-option v-for="role in roleFilterOptions" :key="role.value" :label="role.label" :value="role.value" />
</el-select>
<el-select v-model="filters.allowed" placeholder="结果" clearable style="width: 100px" @change="onFilterChange">
<el-option label="允许" :value="true" />
<el-option label="拒绝" :value="false" />
</el-select>
<el-input
v-model="keyword"
placeholder="搜索用户、IP、属地或接口"
clearable
style="width: 240px"
:prefix-icon="SearchIcon"
/>
</section>
<section class="audit-metrics">
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
<div class="metric-icon">
<component :is="card.icon" />
</div>
<div class="metric-body">
<span class="metric-label">{{ card.label }}</span>
<strong class="metric-value">{{ card.value }}</strong>
</div>
</article>
</section>
<section class="audit-grid">
<article class="ranking-card">
<div class="section-head">
<div class="section-title-group">
<h4>IP访问排行</h4>
<p>按来源 IP 排序账号作为辅助定位</p>
</div>
<el-tag effect="plain" size="small" type="info">TOP {{ userStats.length }}</el-tag>
</div>
<div v-if="userStats.length" class="user-rank-list">
<div v-for="(user, index) in userStats" :key="`${user.sample_ip_address}-${user.user_id}-${user.role}`" class="user-rank-row">
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
<div class="rank-user">
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong>
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small>
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span>
</div>
<div class="rank-count">
<strong>{{ user.total_count }}</strong>
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small>
</div>
</div>
</div>
<el-empty v-else description="暂无用户行为数据" :image-size="72" />
</article>
<article class="log-launcher-card">
<div class="section-head">
<div class="section-title-group">
<h4>日志审计</h4>
<p>查看详细的接口访问与安全事件记录</p>
</div>
</div>
<div class="log-actions">
<div class="log-action-item log-action-interface" @click="openInterfaceLogDialog">
<div class="log-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
</div>
<div class="log-action-content">
<strong>接口访问审计日志</strong>
<span>记录所有 API 接口的访问行为</span>
</div>
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} </el-tag>
</div>
<div v-if="showSecurityLog" class="log-action-item log-action-security" @click="openSecurityLogDialog">
<div class="log-action-icon security">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<div class="log-action-content">
<strong>安全事件审计日志</strong>
<span>异常访问与安全威胁事件</span>
</div>
<el-tag v-if="securitySummary.error_count > 0" effect="dark" size="small" round type="danger">{{ securitySummary.error_count }} 异常</el-tag>
<el-tag v-else effect="plain" size="small" round type="success">正常</el-tag>
</div>
</div>
</article>
</section>
<el-dialog v-model="interfaceLogDialogVisible" title="接口访问审计日志" width="82%" @opened="scrollTerminalToBottom">
<template #header>
<div class="dialog-head">
<strong>接口访问审计日志</strong>
<div class="dialog-actions">
<el-tag effect="plain" type="info">最近 {{ terminalLines.length }} </el-tag>
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
</div>
</div>
</template>
<div ref="terminalWindowRef" v-loading="loading" class="terminal-window dialog-terminal-window">
<pre v-if="terminalLines.length" class="terminal-lines"><code>{{ terminalLines.join("\n") }}</code></pre>
<el-empty v-else description="暂无访问流水" :image-size="72" />
</div>
<div class="logs-pagination">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</el-dialog>
<el-dialog v-model="securityLogDialogVisible" title="安全事件审计日志" width="82%" @opened="scrollSecurityTerminalToBottom">
<template #header>
<div class="dialog-head">
<strong>安全事件审计日志</strong>
<div class="dialog-actions">
<el-tag effect="plain" type="danger">异常 {{ securitySummary.error_count }}</el-tag>
<el-button size="small" type="primary" @click="downloadSecurityLog">下载日志</el-button>
</div>
</div>
</template>
<div ref="securityTerminalWindowRef" v-loading="securityLoading" class="terminal-window dialog-terminal-window">
<pre v-if="securityTerminalLines.length" class="terminal-lines"><code>{{ securityTerminalLines.join("\n") }}</code></pre>
<el-empty v-else description="暂无异常安全访问" :image-size="72" />
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, h, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { fetchAccessLogs, fetchSecurityAccessLogs } from "@/api/projectPermissions";
import type {
AccessLogItem,
AccessLogsSummary,
AccessLogUserStat,
SecurityAccessLogItem,
SecurityAccessLogsResponse,
} from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
showSecurityLog: false,
});
const showSecurityLog = computed(() => props.showSecurityLog);
const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();
const MetricIconVisit = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }),
h("circle", { cx: "12", cy: "12", r: "3" }),
]);
const MetricIconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
h("circle", { cx: "9", cy: "7", r: "4" }),
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
]);
const MetricIconDeny = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("line", { x1: "4.93", y1: "4.93", x2: "19.07", y2: "19.07" }),
]);
const MetricIconSpeed = () => 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 REALTIME_POLL_INTERVAL_MS = 3000;
const emptySummary: AccessLogsSummary = {
total_count: 0,
unique_user_count: 0,
unique_ip_count: 0,
denied_count: 0,
avg_elapsed_ms: 0,
};
const emptySecuritySummary: SecurityAccessLogsResponse["summary"] = {
total_count: 0,
anonymous_count: 0,
invalid_token_count: 0,
error_count: 0,
};
const loading = ref(false);
const securityLoading = ref(false);
const logs = ref<AccessLogItem[]>([]);
const securityLogs = ref<SecurityAccessLogItem[]>([]);
const userStats = ref<AccessLogUserStat[]>([]);
const summary = ref<AccessLogsSummary>(emptySummary);
const securitySummary = ref<SecurityAccessLogsResponse["summary"]>(emptySecuritySummary);
const total = ref(0);
const page = ref(1);
const pageSize = 50;
const dateRange = ref<[Date, Date] | null>(null);
const keyword = ref("");
const interfaceLogDialogVisible = ref(false);
const securityLogDialogVisible = ref(false);
const terminalWindowRef = ref<HTMLElement | null>(null);
const securityTerminalWindowRef = ref<HTMLElement | null>(null);
const realtimeTimer = ref<number | null>(null);
const filters = reactive({
role: undefined as string | undefined,
allowed: undefined as boolean | undefined,
});
const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "QA", "CTA"];
const roleFilterOptions = computed(() => roleOptionsFor(ROLE_FILTER_KEYS));
const SECURITY_AUTH_LABELS: Record<string, string> = {
ANONYMOUS: "匿名",
INVALID_TOKEN: "无效令牌",
AUTHENTICATED: "已认证",
};
const formatTerminalTime = (iso: string) => {
const date = new Date(iso);
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
const formatIpLocation = (row: AccessLogItem) => {
const parts = [row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
return parts.length ? parts.join(" / ") : row.ip_location;
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const metricCards = computed(() => [
{
label: "访问次数",
value: formatNumber(summary.value.total_count),
tone: "blue",
icon: MetricIconVisit,
},
{
label: "访问用户数",
value: formatNumber(summary.value.unique_user_count),
tone: "cyan",
icon: MetricIconUsers,
},
{
label: "拒绝次数",
value: formatNumber(summary.value.denied_count),
tone: summary.value.denied_count > 0 ? "danger" : "violet",
icon: MetricIconDeny,
},
{
label: "平均耗时",
value: `${summary.value.avg_elapsed_ms.toFixed(1)}ms`,
tone: "indigo",
icon: MetricIconSpeed,
},
]);
const buildTerminalLine = (row: AccessLogItem) => {
const result = row.allowed ? "ALLOW" : "DENY ";
const user = row.user_name || "未知用户";
const ip = row.ip_address || "-";
const location = formatIpLocation(row) || "未知属地";
return `${formatTerminalTime(row.created_at)} [${result}] role=${roleLabel(row.role)} user=${user} ip=${ip} loc=${location} endpoint=${row.endpoint_key} cost=${row.elapsed_ms.toFixed(1)}ms`;
};
const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
const ip = row.client_ip || "未知 IP";
const account = row.account_label || "未知账号";
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
const userAgent = row.user_agent || "-";
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
};
const terminalLogRows = computed(() =>
[...logs.value].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
);
const terminalLines = computed(() => {
const token = keyword.value.trim().toLowerCase();
const lines = terminalLogRows.value.map(buildTerminalLine);
if (!token) return lines;
return lines.filter((line) => line.toLowerCase().includes(token));
});
const securityTerminalLines = computed(() => {
const token = keyword.value.trim().toLowerCase();
const lines = [...securityLogs.value]
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.map(buildSecurityTerminalLine);
if (!token) return lines;
return lines.filter((line) => line.toLowerCase().includes(token));
});
const onFilterChange = () => {
page.value = 1;
loadData();
};
const buildQueryParams = () => {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize,
};
if (filters.role) params.role = filters.role;
if (filters.allowed !== undefined) params.allowed = filters.allowed;
if (dateRange.value) {
params.start_time = dateRange.value[0].toISOString();
params.end_time = dateRange.value[1].toISOString();
}
return params;
};
const scrollTerminalToBottom = () => {
nextTick(() => {
const terminal = terminalWindowRef.value;
if (!terminal) return;
terminal.scrollTop = terminal.scrollHeight;
});
};
const scrollSecurityTerminalToBottom = () => {
nextTick(() => {
const terminal = securityTerminalWindowRef.value;
if (!terminal) return;
terminal.scrollTop = terminal.scrollHeight;
});
};
const loadData = async (options: { silent?: boolean } = {}) => {
if (!options.silent) loading.value = true;
try {
const res = await fetchAccessLogs(buildQueryParams());
logs.value = res.data.items;
total.value = res.data.total;
summary.value = res.data.summary || emptySummary;
userStats.value = res.data.user_stats || [];
if (interfaceLogDialogVisible.value) scrollTerminalToBottom();
} catch {
if (options.silent) return;
logs.value = [];
total.value = 0;
summary.value = emptySummary;
userStats.value = [];
} finally {
loading.value = false;
}
};
const loadSecurityData = async (options: { silent?: boolean } = {}) => {
if (!showSecurityLog.value) return;
if (!options.silent) securityLoading.value = true;
try {
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
securityLogs.value = res.data.items;
securitySummary.value = res.data.summary || emptySecuritySummary;
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
} catch {
if (options.silent) return;
securityLogs.value = [];
securitySummary.value = emptySecuritySummary;
} finally {
securityLoading.value = false;
}
};
const stopRealtimePolling = () => {
if (!realtimeTimer.value) return;
window.clearInterval(realtimeTimer.value);
realtimeTimer.value = null;
};
const startRealtimePolling = () => {
stopRealtimePolling();
realtimeTimer.value = window.setInterval(() => {
loadData({ silent: true });
if (showSecurityLog.value) loadSecurityData({ silent: true });
}, REALTIME_POLL_INTERVAL_MS);
};
const refresh = () => {
loadData();
if (showSecurityLog.value) loadSecurityData();
};
const openInterfaceLogDialog = () => {
interfaceLogDialogVisible.value = true;
loadData({ silent: true });
scrollTerminalToBottom();
};
const openSecurityLogDialog = () => {
if (!showSecurityLog.value) return;
securityLogDialogVisible.value = true;
loadSecurityData({ silent: true });
scrollSecurityTerminalToBottom();
};
const downloadLogFile = (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
};
const downloadInterfaceLog = () => {
downloadLogFile("interface-access-audit.log", terminalLines.value);
};
const downloadSecurityLog = () => {
downloadLogFile("security-event-audit.log", securityTerminalLines.value);
};
onMounted(async () => {
await loadRoleTemplates();
refresh();
startRealtimePolling();
});
onBeforeUnmount(stopRealtimePolling);
defineExpose({ refresh });
</script>
<style scoped>
.access-logs {
--audit-ink: #1a2332;
--audit-muted: #64748b;
--audit-border: #e2e8f0;
--audit-blue: #3b82f6;
--audit-cyan: #06b6d4;
--audit-violet: #8b5cf6;
--audit-danger: #ef4444;
--audit-indigo: #6366f1;
--card-radius: 16px;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
gap: 16px;
}
.audit-filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
padding: 14px 18px;
background: #fff;
border-radius: var(--card-radius);
border: 1px solid var(--audit-border);
box-shadow: var(--card-shadow);
}
.audit-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 14px;
min-height: 64px;
padding: 10px 16px;
border-radius: var(--card-radius);
background: #fff;
border: 1px solid var(--audit-border);
box-shadow: var(--card-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.metric-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.metric-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
}
.metric-icon svg {
width: 22px;
height: 22px;
}
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
.metric-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.metric-label {
color: var(--audit-muted);
font-size: 13px;
font-weight: 500;
}
.metric-value {
color: var(--audit-ink);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.audit-grid {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
gap: 14px;
}
.ranking-card,
.log-launcher-card {
min-width: 0;
padding: 20px;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
box-shadow: var(--card-shadow);
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-title-group h4 {
margin: 0;
color: var(--audit-ink);
font-size: 16px;
font-weight: 600;
}
.section-title-group p {
margin: 4px 0 0;
color: var(--audit-muted);
font-size: 12px;
}
.user-rank-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-rank-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 12px 10px;
border-radius: 10px;
transition: background 0.15s ease;
}
.user-rank-row:hover {
background: #f8fafc;
}
.rank-no {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.rank-no.rank-top {
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: #fff;
}
.rank-user,
.rank-count {
display: flex;
flex-direction: column;
gap: 3px;
}
.rank-user strong {
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
}
.rank-user small {
color: var(--audit-muted);
font-size: 12px;
}
.rank-location {
color: #94a3b8;
font-size: 11px;
}
.rank-count {
align-items: flex-end;
}
.rank-count strong {
color: var(--audit-ink);
font-size: 18px;
font-weight: 700;
}
.rank-count small {
color: var(--audit-muted);
font-size: 11px;
}
.rank-count .denied-highlight {
color: var(--audit-danger);
font-weight: 600;
}
.log-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.log-action-item {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.log-action-interface {
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
border: 1px solid #bae6fd;
}
.log-action-interface:hover {
background: linear-gradient(135deg, #e0f2fe, #bae6fd);
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.12);
transform: translateY(-1px);
}
.log-action-security {
background: linear-gradient(135deg, #fef2f2, #fee2e2);
border: 1px solid #fecaca;
}
.log-action-security:hover {
background: linear-gradient(135deg, #fee2e2, #fecaca);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
transform: translateY(-1px);
}
.log-action-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: #fff;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.log-action-icon svg {
width: 20px;
height: 20px;
color: #0ea5e9;
}
.log-action-icon.security svg {
color: #ef4444;
}
.log-action-content {
flex: 1;
min-width: 0;
}
.log-action-content strong {
display: block;
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
}
.log-action-content span {
display: block;
margin-top: 2px;
color: var(--audit-muted);
font-size: 12px;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.dialog-head strong {
color: var(--audit-ink);
font-size: 17px;
}
.dialog-actions {
display: flex;
align-items: center;
gap: 8px;
}
.terminal-window {
min-height: 360px;
max-height: 520px;
overflow: auto;
padding: 16px;
border: 1px solid #1e293b;
border-radius: 12px;
background: linear-gradient(180deg, #1e293b, #0f172a);
}
.dialog-terminal-window {
height: 62vh;
min-height: 360px;
max-height: 68vh;
}
.terminal-lines {
margin: 0;
color: #e2e8f0;
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
font-size: 12.5px;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-word;
}
.logs-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 1100px) {
.audit-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.audit-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.audit-metrics {
grid-template-columns: 1fr;
}
}
</style>