Redesign admin UI with Chinese light theme

This commit is contained in:
2026-03-04 15:00:52 +08:00
parent f212b68c2c
commit 4348ee799b
8 changed files with 363 additions and 424 deletions

View File

@@ -14,14 +14,14 @@ let clearAnnouncementTimer
let unsubscribeAnnouncements = () => {}
const navItems = [
{ label: 'Dashboard', name: 'dashboard', icon: 'DataAnalysis' },
{ label: 'Bindings', name: 'bindings', icon: 'Connection' },
{ label: 'Logs', name: 'logs', icon: 'WarningFilled' },
{ label: 'Settings', name: 'settings', icon: 'Setting' },
{ label: '总览看板', name: 'dashboard', icon: 'DataAnalysis' },
{ label: '绑定管理', name: 'bindings', icon: 'Connection' },
{ label: '拦截日志', name: 'logs', icon: 'WarningFilled' },
{ label: '运行设置', name: 'settings', icon: 'Setting' },
]
const hideShell = computed(() => Boolean(route.meta.public))
const currentSection = computed(() => route.meta.kicker || 'Operations')
const currentSection = computed(() => route.meta.kicker || '控制台')
function updateClock() {
clockLabel.value = new Intl.DateTimeFormat(undefined, {
@@ -65,7 +65,7 @@ onBeforeUnmount(() => {
<router-view v-if="hideShell" />
<div v-else class="shell">
<a class="skip-link" href="#main-content">Skip to main content</a>
<a class="skip-link" href="#main-content">跳转到主要内容</a>
<div class="shell-glow shell-glow--mint" />
<div class="shell-glow shell-glow--amber" />
@@ -74,8 +74,8 @@ onBeforeUnmount(() => {
<div class="brand-mark">S</div>
<div>
<p class="eyebrow">Key-IP Sentinel</p>
<h1 class="brand-title">Control Plane</h1>
<p class="brand-subtitle">First-use bind enforcement edge</p>
<h1 class="brand-title">安全控制台</h1>
<p class="brand-subtitle">API Key 首次使用 IP 绑定网关</p>
</div>
</div>
@@ -93,23 +93,23 @@ onBeforeUnmount(() => {
</nav>
<div class="sidebar-note">
<p class="eyebrow">Operating mode</p>
<h3>Zero-trust token perimeter</h3>
<p class="eyebrow">当前能力</p>
<h3>绑定审计告警一体化</h3>
<p class="muted">
Every API key is pinned to the first observed client address or CIDR and inspected at the edge.
所有请求先经过边界网关首次调用自动绑定来源地址后续按 IP CIDR 持续校验
</p>
</div>
<div class="rail-grid">
<div class="rail-card">
<span class="rail-label">Surface</span>
<strong>Admin UI</strong>
<span class="rail-meta">JWT protected</span>
<span class="rail-label">入口</span>
<strong>管理后台</strong>
<span class="rail-meta">JWT 鉴权</span>
</div>
<div class="rail-card">
<span class="rail-label">Proxy</span>
<strong>Streaming</strong>
<span class="rail-meta">SSE passthrough</span>
<span class="rail-label">网关</span>
<strong>流式代理</strong>
<span class="rail-meta">支持 SSE 透传</span>
</div>
</div>
</aside>
@@ -119,20 +119,20 @@ onBeforeUnmount(() => {
<div class="header-copy">
<p class="eyebrow">{{ currentSection }}</p>
<h2 id="page-title" class="page-title">{{ route.meta.title || 'Sentinel' }}</h2>
<p class="muted header-note">Edge policy, runtime settings, and operator visibility in one secure surface.</p>
<p class="muted header-note">围绕绑定记录拦截日志和运行设置的统一运维入口</p>
</div>
<div class="header-actions">
<div class="header-chip-group">
<div class="header-chip">
<span class="header-chip-label">Mode</span>
<strong>Secure Proxy</strong>
<span class="header-chip-label">模式</span>
<strong>安全代理</strong>
</div>
<div class="header-chip" aria-live="polite">
<span class="header-chip-label">Updated</span>
<span class="header-chip-label">时间</span>
<strong>{{ clockLabel }}</strong>
</div>
</div>
<el-button type="primary" plain @click="logout">Logout</el-button>
<el-button type="primary" plain @click="logout">退出登录</el-button>
</div>
</header>
@@ -147,10 +147,10 @@ onBeforeUnmount(() => {
.shell {
position: relative;
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 24px;
grid-template-columns: 276px minmax(0, 1fr);
gap: 18px;
min-height: 100vh;
padding: 24px;
padding: 18px;
}
.shell-sidebar,
@@ -162,8 +162,8 @@ onBeforeUnmount(() => {
.shell-sidebar {
display: flex;
flex-direction: column;
gap: 28px;
padding: 28px;
gap: 20px;
padding: 22px;
}
.brand-block {
@@ -175,19 +175,19 @@ onBeforeUnmount(() => {
.brand-mark {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(17, 231, 181, 0.95), rgba(21, 132, 214, 0.95));
color: #071016;
font-size: 1.45rem;
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, #6ea7ff, #86c8ff);
color: #ffffff;
font-size: 1.2rem;
font-weight: 800;
}
.brand-title,
.page-title {
margin: 0;
font-size: clamp(1.5rem, 2vw, 2.1rem);
font-size: clamp(1.2rem, 1.6vw, 1.6rem);
}
.nav-list {
@@ -199,19 +199,20 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
padding: 12px 14px;
border-radius: 16px;
color: var(--sentinel-ink-soft);
text-decoration: none;
transition: transform 160ms ease, background 160ms ease, color 160ms ease, box-shadow 160ms ease;
font-size: 0.95rem;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sentinel-ink);
background: rgba(7, 176, 147, 0.14);
box-shadow: inset 0 0 0 1px rgba(7, 176, 147, 0.18);
transform: translateX(4px);
background: rgba(114, 163, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(114, 163, 255, 0.2);
transform: translateX(3px);
}
.nav-icon {
@@ -220,15 +221,16 @@ onBeforeUnmount(() => {
.sidebar-note {
margin-top: auto;
padding: 18px;
padding: 16px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(10, 26, 35, 0.8));
color: #f3fffd;
background: linear-gradient(180deg, rgba(244, 248, 255, 0.98), rgba(235, 243, 255, 0.92));
color: var(--sentinel-ink);
border: 1px solid rgba(122, 164, 255, 0.18);
}
.sidebar-note h3 {
margin: 10px 0;
font-size: 1.15rem;
font-size: 1rem;
}
.shell-main {
@@ -236,7 +238,7 @@ onBeforeUnmount(() => {
z-index: 1;
display: flex;
flex-direction: column;
gap: 24px;
gap: 18px;
min-width: 0;
}
@@ -244,8 +246,8 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 22px 26px;
gap: 16px;
padding: 18px 20px;
}
.header-actions {
@@ -257,7 +259,7 @@ onBeforeUnmount(() => {
.shell-content {
display: flex;
flex-direction: column;
gap: 24px;
gap: 18px;
min-width: 0;
}
@@ -272,19 +274,19 @@ onBeforeUnmount(() => {
}
.shell-glow--mint {
top: 80px;
right: 160px;
width: 240px;
height: 240px;
background: rgba(17, 231, 181, 0.22);
top: 60px;
right: 120px;
width: 220px;
height: 220px;
background: rgba(132, 196, 255, 0.2);
}
.shell-glow--amber {
bottom: 100px;
left: 420px;
width: 280px;
height: 280px;
background: rgba(255, 170, 76, 0.18);
bottom: 80px;
left: 360px;
width: 260px;
height: 260px;
background: rgba(177, 221, 255, 0.18);
}
@media (max-width: 1080px) {

View File

@@ -16,7 +16,7 @@ const router = createRouter({
component: Login,
meta: {
public: true,
title: 'Admin Login',
title: '管理员登录',
},
},
{
@@ -28,8 +28,8 @@ const router = createRouter({
name: 'dashboard',
component: Dashboard,
meta: {
title: 'Traffic Pulse',
kicker: 'Observability',
title: '总览看板',
kicker: '运行概览',
},
},
{
@@ -37,8 +37,8 @@ const router = createRouter({
name: 'bindings',
component: Bindings,
meta: {
title: 'Token Bindings',
kicker: 'Control',
title: '绑定管理',
kicker: '绑定控制',
},
},
{
@@ -46,8 +46,8 @@ const router = createRouter({
name: 'logs',
component: Logs,
meta: {
title: 'Intercept Logs',
kicker: 'Audit',
title: '拦截日志',
kicker: '审计追踪',
},
},
{
@@ -55,8 +55,8 @@ const router = createRouter({
name: 'settings',
component: Settings,
meta: {
title: 'Runtime Settings',
kicker: 'Operations',
title: '运行设置',
kicker: '运行配置',
},
},
],

View File

@@ -1,22 +1,22 @@
:root {
--sentinel-bg: #08131c;
--sentinel-bg-soft: #102734;
--sentinel-panel: rgba(252, 255, 255, 0.82);
--sentinel-panel-strong: rgba(255, 255, 255, 0.9);
--sentinel-border: rgba(255, 255, 255, 0.24);
--sentinel-ink: #09161e;
--sentinel-ink-soft: #57717d;
--sentinel-accent: #07b093;
--sentinel-accent-deep: #0d7e8b;
--sentinel-warn: #ef7f41;
--sentinel-danger: #dc4f53;
--sentinel-shadow: 0 30px 80px rgba(2, 12, 18, 0.22);
--el-color-primary: #0b9e88;
--el-color-success: #1aa36f;
--el-color-warning: #ef7f41;
--el-color-danger: #dc4f53;
--sentinel-bg: #eef5ff;
--sentinel-bg-soft: #dfeefe;
--sentinel-panel: rgba(255, 255, 255, 0.9);
--sentinel-panel-strong: rgba(255, 255, 255, 0.96);
--sentinel-border: rgba(113, 157, 226, 0.18);
--sentinel-ink: #17324d;
--sentinel-ink-soft: #66809c;
--sentinel-accent: #4d8ff7;
--sentinel-accent-deep: #2d6fd5;
--sentinel-warn: #f29a44;
--sentinel-danger: #df5b67;
--sentinel-shadow: 0 20px 48px rgba(46, 92, 146, 0.12);
--el-color-primary: #4d8ff7;
--el-color-success: #36a980;
--el-color-warning: #f29a44;
--el-color-danger: #df5b67;
color: var(--sentinel-ink);
font-family: "Avenir Next", "Segoe UI Variable", "Segoe UI", "PingFang SC", sans-serif;
font-family: "PingFang SC", "Microsoft YaHei UI", "Segoe UI Variable", "Noto Sans SC", sans-serif;
line-height: 1.5;
font-weight: 400;
}
@@ -32,11 +32,11 @@
html {
min-height: 100%;
color-scheme: dark;
color-scheme: light;
background:
radial-gradient(circle at top left, rgba(12, 193, 152, 0.22), transparent 34%),
radial-gradient(circle at top right, rgba(255, 170, 76, 0.18), transparent 30%),
linear-gradient(180deg, #09131d 0%, #0d1d29 35%, #112d3d 100%);
radial-gradient(circle at top left, rgba(146, 198, 255, 0.48), transparent 34%),
radial-gradient(circle at top right, rgba(215, 234, 255, 0.72), transparent 32%),
linear-gradient(180deg, #f4f8ff 0%, #edf5ff 40%, #e5f0fd 100%);
}
body {
@@ -62,10 +62,10 @@ body::before {
position: fixed;
inset: 0;
background:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
linear-gradient(rgba(77, 143, 247, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(77, 143, 247, 0.05) 1px, transparent 1px);
background-size: 34px 34px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.5), transparent 95%);
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.32), transparent 95%);
pointer-events: none;
}
@@ -105,22 +105,22 @@ body::before {
.panel {
background: var(--sentinel-panel);
border: 1px solid var(--sentinel-border);
border-radius: 28px;
backdrop-filter: blur(18px);
border-radius: 24px;
backdrop-filter: blur(12px);
box-shadow: var(--sentinel-shadow);
min-width: 0;
}
.glass-panel {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(250, 255, 252, 0.74));
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 249, 255, 0.84));
}
.eyebrow {
margin: 0;
color: var(--sentinel-accent-deep);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.74rem;
letter-spacing: 0.14em;
font-size: 0.68rem;
font-weight: 700;
}
@@ -130,19 +130,19 @@ body::before {
.page-grid {
display: grid;
gap: 24px;
gap: 18px;
}
.hero-panel {
position: relative;
padding: 26px;
padding: 20px 22px;
overflow: hidden;
}
.hero-layout {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
gap: 20px;
gap: 16px;
align-items: stretch;
}
@@ -177,7 +177,7 @@ body::before {
right: -40px;
width: 220px;
height: 220px;
background: radial-gradient(circle, rgba(7, 176, 147, 0.28), transparent 70%);
background: radial-gradient(circle, rgba(98, 168, 255, 0.22), transparent 70%);
pointer-events: none;
}
@@ -187,24 +187,24 @@ body::before {
.page-title,
.login-stage h1 {
margin: 10px 0 8px;
font-size: 1.4rem;
font-size: 1.16rem;
text-wrap: balance;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
gap: 12px;
}
.metric-card {
position: relative;
overflow: hidden;
padding: 20px;
padding: 16px;
}
.metric-card--enhanced {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(244, 252, 249, 0.78));
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(243, 248, 255, 0.88));
}
.metric-card::before {
@@ -214,20 +214,20 @@ body::before {
width: 140px;
height: 140px;
border-radius: 999px;
background: radial-gradient(circle, rgba(7, 176, 147, 0.16), transparent 70%);
background: radial-gradient(circle, rgba(98, 168, 255, 0.14), transparent 70%);
}
.metric-card[data-accent="amber"]::before {
background: radial-gradient(circle, rgba(239, 127, 65, 0.16), transparent 70%);
background: radial-gradient(circle, rgba(242, 154, 68, 0.18), transparent 70%);
}
.metric-card[data-accent="slate"]::before {
background: radial-gradient(circle, rgba(54, 97, 135, 0.16), transparent 70%);
background: radial-gradient(circle, rgba(113, 157, 226, 0.16), transparent 70%);
}
.metric-value {
margin: 10px 0 0;
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-size: clamp(1.45rem, 2.3vw, 2rem);
font-weight: 800;
font-variant-numeric: tabular-nums;
}
@@ -240,7 +240,7 @@ body::before {
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr);
gap: 24px;
gap: 18px;
}
.content-grid--balanced {
@@ -250,7 +250,7 @@ body::before {
.chart-card,
.table-card,
.form-card {
padding: 24px;
padding: 18px;
}
.chart-surface {
@@ -294,7 +294,7 @@ body::before {
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
gap: 10px;
align-items: center;
justify-content: space-between;
}
@@ -310,21 +310,21 @@ body::before {
.data-table .el-table {
--el-table-border-color: rgba(9, 22, 30, 0.08);
--el-table-header-bg-color: rgba(7, 176, 147, 0.08);
--el-table-row-hover-bg-color: rgba(7, 176, 147, 0.05);
border-radius: 18px;
--el-table-header-bg-color: rgba(92, 151, 255, 0.1);
--el-table-row-hover-bg-color: rgba(92, 151, 255, 0.05);
border-radius: 16px;
overflow: hidden;
}
.el-button {
min-height: 44px;
min-height: 38px;
}
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner,
.el-date-editor .el-input__wrapper {
min-height: 44px;
min-height: 38px;
}
.soft-grid {
@@ -333,15 +333,15 @@ body::before {
}
.support-card {
padding: 20px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(243, 251, 248, 0.72));
border: 1px solid rgba(255, 255, 255, 0.32);
padding: 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(242, 247, 255, 0.82));
border: 1px solid rgba(113, 157, 226, 0.14);
}
.support-card h4 {
margin: 10px 0 8px;
font-size: 1.08rem;
font-size: 0.98rem;
}
.support-card p {
@@ -384,10 +384,10 @@ body::before {
}
.insight-card {
padding: 18px 20px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(12, 24, 33, 0.8));
color: #f2fffd;
padding: 16px 18px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(80, 134, 236, 0.95), rgba(70, 123, 224, 0.9));
color: #f7fbff;
}
.insight-value {
@@ -425,8 +425,8 @@ body::before {
min-height: 100vh;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 24px;
padding: 24px;
gap: 18px;
padding: 18px;
}
.login-stage,
@@ -436,20 +436,20 @@ body::before {
}
.login-stage {
padding: 42px;
padding: 30px;
display: flex;
flex-direction: column;
gap: 28px;
gap: 22px;
justify-content: space-between;
color: #f7fffe;
color: #eef6ff;
background:
radial-gradient(circle at top left, rgba(17, 231, 181, 0.24), transparent 28%),
linear-gradient(160deg, rgba(8, 24, 34, 0.95), rgba(15, 37, 50, 0.92));
radial-gradient(circle at top left, rgba(255, 255, 255, 0.24), transparent 30%),
linear-gradient(160deg, rgba(92, 151, 255, 0.98), rgba(109, 176, 255, 0.92));
}
.login-stage h1 {
margin: 12px 0;
font-size: clamp(2.4rem, 4vw, 4rem);
font-size: clamp(2rem, 3vw, 3rem);
line-height: 0.96;
}
@@ -462,14 +462,14 @@ body::before {
.login-card {
display: grid;
place-items: center;
padding: 36px;
padding: 24px;
}
.login-card-inner {
width: min(100%, 460px);
padding: 34px;
padding: 28px;
background: var(--sentinel-panel-strong);
border-radius: 32px;
border-radius: 26px;
border: 1px solid var(--sentinel-border);
box-shadow: var(--sentinel-shadow);
}
@@ -480,10 +480,10 @@ body::before {
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(7, 176, 147, 0.12);
background: rgba(92, 151, 255, 0.12);
color: var(--sentinel-accent-deep);
font-weight: 700;
font-size: 0.82rem;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
@@ -516,10 +516,10 @@ body::before {
.rail-card {
display: grid;
gap: 4px;
padding: 14px 16px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.44);
border: 1px solid rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.74);
border: 1px solid rgba(113, 157, 226, 0.14);
}
.rail-label,
@@ -554,10 +554,10 @@ body::before {
display: grid;
gap: 2px;
min-width: 140px;
padding: 10px 14px;
padding: 9px 12px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.56);
border: 1px solid rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(113, 157, 226, 0.16);
}
.header-chip strong {
@@ -571,16 +571,16 @@ body::before {
}
.hero-stat {
padding: 14px 16px;
padding: 12px 14px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 255, 252, 0.64));
border: 1px solid rgba(255, 255, 255, 0.36);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(243, 248, 255, 0.78));
border: 1px solid rgba(113, 157, 226, 0.16);
}
.hero-stat strong {
display: block;
margin-top: 6px;
font-size: 1.35rem;
font-size: 1.15rem;
font-variant-numeric: tabular-nums;
}
@@ -618,7 +618,7 @@ body::before {
.filter-label {
color: var(--sentinel-ink);
font-size: 0.82rem;
font-size: 0.78rem;
font-weight: 700;
}

View File

@@ -138,22 +138,22 @@ function isDormant(row) {
function formatLastSeen(value) {
if (!value) {
return 'No activity'
return '暂无记录'
}
const elapsedHours = Math.floor((Date.now() - new Date(value).getTime()) / 3600000)
if (elapsedHours < 1) {
return 'Active within 1h'
return '1 小时内活跃'
}
if (elapsedHours < 24) {
return `Active ${elapsedHours}h ago`
return `${elapsedHours} 小时前活跃`
}
const days = Math.floor(elapsedHours / 24)
if (days < staleWindowDays) {
return `Active ${days}d ago`
return `${days} 天前活跃`
}
return `Dormant ${days}d`
return `沉寂 ${days}`
}
function statusTone(row) {
@@ -165,19 +165,19 @@ function statusTone(row) {
function statusText(row) {
if (row.status === 2) {
return 'Banned'
return '已封禁'
}
return isDormant(row) ? 'Dormant' : 'Healthy'
return isDormant(row) ? '沉寂' : '正常'
}
function ipTypeLabel(boundIp) {
if (!boundIp) {
return 'Unknown'
return '未知'
}
if (!boundIp.includes('/')) {
return 'Single IP'
return '单个 IP'
}
return boundIp.endsWith('/32') || boundIp.endsWith('/128') ? 'Single IP' : 'CIDR'
return boundIp.endsWith('/32') || boundIp.endsWith('/128') ? '单个 IP' : 'CIDR 网段'
}
function rowClassName({ row }) {
@@ -193,9 +193,9 @@ function rowClassName({ row }) {
async function copyValue(value, label) {
try {
await navigator.clipboard.writeText(String(value))
ElMessage.success(`${label} copied.`)
ElMessage.success(`${label}已复制。`)
} catch {
ElMessage.error(`Failed to copy ${label.toLowerCase()}.`)
ElMessage.error(`复制${label}失败。`)
}
}
@@ -204,7 +204,7 @@ async function loadBindings() {
const data = await fetchBindings(requestParams())
rows.value = data.items
total.value = data.total
}, 'Failed to load bindings.')
}, '加载绑定列表失败。')
}
async function refreshBindings() {
@@ -244,12 +244,12 @@ function openEdit(row) {
async function submitEdit() {
if (!form.bound_ip) {
ElMessage.warning('Provide a CIDR or single IP.')
ElMessage.warning('请输入 CIDR 或单个 IP')
return
}
try {
await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.')
ElMessage.success('Binding updated.')
await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), '更新绑定失败。')
ElMessage.success('绑定地址已更新。')
dialogVisible.value = false
await refreshBindings()
} catch {}
@@ -257,12 +257,12 @@ async function submitEdit() {
async function confirmAction(title, action) {
try {
await ElMessageBox.confirm(title, 'Confirm action', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
await ElMessageBox.confirm(title, '确认操作', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
await run(action, 'Operation failed.')
await run(action, '操作失败。')
await refreshBindings()
} catch (error) {
if (error === 'cancel') {
@@ -295,63 +295,61 @@ watch(
<template>
<div class="page-grid">
<PageHero
eyebrow="Binding control"
title="Operate token-to-IP bindings from one dense, searchable workbench"
description="Search quickly, verify the last seen address, then edit CIDRs or remove stale registrations without hunting through secondary cards."
eyebrow="绑定控制"
title="围绕绑定表格完成查询、核对与处置"
description="按 Token 尾号或绑定地址快速检索,确认最近活跃时间后直接编辑 CIDR、解绑或封禁。"
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Matching total</span>
<span class="eyebrow">匹配总数</span>
<strong>{{ formatCompactNumber(total) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Needs review</span>
<span class="eyebrow">待关注</span>
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button :icon="RefreshRight" plain @click="refreshBindings">Refresh</el-button>
<el-button :icon="RefreshRight" plain @click="refreshBindings">刷新</el-button>
</template>
</PageHero>
<section class="binding-workbench panel">
<div class="binding-head">
<div class="binding-head-copy">
<p class="eyebrow">Binding registry</p>
<h3 class="section-title">Search, compare, and intervene fast</h3>
<p class="muted">
Focus stays on the table: search by token suffix or IP, inspect last activity, then change CIDR, unbind, or ban in place.
</p>
<p class="eyebrow">绑定列表</p>
<h3 class="section-title">聚焦表格本身减少干扰信息</h3>
<p class="muted">页面只保留查询状态和处置动作方便快速完成 IP 管理</p>
</div>
<div class="binding-summary-strip" aria-label="Binding summary">
<article class="binding-summary-card">
<span class="binding-summary-label">Visible window</span>
<span class="binding-summary-label">当前范围</span>
<strong>{{ currentWindowLabel }}</strong>
<span class="muted">of {{ formatCompactNumber(total) }}</span>
<span class="muted"> {{ formatCompactNumber(total) }} </span>
</article>
<article class="binding-summary-card">
<span class="binding-summary-label">Active on page</span>
<span class="binding-summary-label">当前页正常</span>
<strong>{{ formatCompactNumber(activeCount) }}</strong>
<span class="muted">healthy rows</span>
<span class="muted">可继续放行</span>
</article>
<article class="binding-summary-card binding-summary-card--warn">
<span class="binding-summary-label">Attention</span>
<span class="binding-summary-label">需要关注</span>
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
<span class="muted">banned or dormant</span>
<span class="muted">封禁或长期不活跃</span>
</article>
<article class="binding-summary-card binding-summary-card--danger">
<span class="binding-summary-label">Banned</span>
<span class="binding-summary-label">已封禁</span>
<strong>{{ formatCompactNumber(bannedCount) }}</strong>
<span class="muted">blocked rows</span>
<span class="muted">已阻断</span>
</article>
</div>
</div>
<div class="binding-filter-grid">
<div class="filter-field field-sm">
<label class="filter-label" for="binding-token-suffix">Token suffix</label>
<label class="filter-label" for="binding-token-suffix">Token 尾号</label>
<el-input
id="binding-token-suffix"
v-model="filters.token_suffix"
@@ -359,7 +357,7 @@ watch(
autocomplete="off"
clearable
name="binding_token_suffix"
placeholder="sk...tail"
placeholder="输入 Token 尾号"
@keyup.enter="searchBindings"
>
<template #prefix>
@@ -369,7 +367,7 @@ watch(
</div>
<div class="filter-field field-md">
<label class="filter-label" for="binding-ip-filter">Bound IP or CIDR</label>
<label class="filter-label" for="binding-ip-filter">绑定 IP / CIDR</label>
<el-input
id="binding-ip-filter"
v-model="filters.ip"
@@ -387,35 +385,35 @@ watch(
</div>
<div class="filter-field field-status">
<label class="filter-label" for="binding-status-filter">Status</label>
<label class="filter-label" for="binding-status-filter">状态</label>
<el-select
id="binding-status-filter"
v-model="filters.status"
aria-label="Filter by binding status"
clearable
placeholder="Any status"
placeholder="全部状态"
>
<el-option label="Active" :value="1" />
<el-option label="Banned" :value="2" />
<el-option label="正常" :value="1" />
<el-option label="封禁" :value="2" />
</el-select>
</div>
<div class="filter-field field-page-size">
<label class="filter-label" for="binding-page-size">Page size</label>
<label class="filter-label" for="binding-page-size">每页条数</label>
<el-select
id="binding-page-size"
v-model="filters.page_size"
aria-label="Bindings page size"
@change="onPageSizeChange"
>
<el-option v-for="size in pageSizeOptions" :key="size" :label="`${size} rows`" :value="size" />
<el-option v-for="size in pageSizeOptions" :key="size" :label="`${size} `" :value="size" />
</el-select>
</div>
<div class="binding-actions">
<el-button @click="resetFilters">Reset</el-button>
<el-button :icon="RefreshRight" plain :loading="loading" @click="refreshBindings">Reload</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="searchBindings">Apply filters</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :icon="RefreshRight" plain :loading="loading" @click="refreshBindings">重新加载</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="searchBindings">应用筛选</el-button>
</div>
</div>
@@ -423,50 +421,50 @@ watch(
<div class="inline-meta">
<span class="status-chip">
<el-icon><SwitchButton /></el-icon>
{{ formatCompactNumber(total) }} matched bindings
当前匹配 {{ formatCompactNumber(total) }} 条绑定
</span>
<span class="binding-table-note">Dormant means no activity for {{ staleWindowDays }} days or more.</span>
<span class="binding-table-note">沉寂表示 {{ staleWindowDays }} 天及以上没有请求</span>
</div>
</div>
<div class="data-table binding-table">
<el-table :data="rows" :row-class-name="rowClassName" v-loading="loading">
<el-table-column label="Binding" min-width="220">
<el-table-column label="绑定对象" min-width="220">
<template #default="{ row }">
<div class="binding-token-cell">
<div class="binding-token-main">
<strong>{{ row.token_display }}</strong>
<span class="binding-id">#{{ row.id }}</span>
</div>
<span class="muted">First seen {{ formatDateTime(row.first_used_at) }}</span>
<span class="muted">首次使用{{ formatDateTime(row.first_used_at) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Bound address" min-width="220">
<el-table-column label="绑定地址" min-width="220">
<template #default="{ row }">
<div class="binding-ip-cell">
<div class="binding-ip-line">
<code>{{ row.bound_ip }}</code>
<el-button text :icon="CopyDocument" @click="copyValue(row.bound_ip, 'Bound IP')">Copy</el-button>
<el-button text :icon="CopyDocument" @click="copyValue(row.bound_ip, '绑定地址')">复制</el-button>
</div>
<span class="muted">{{ ipTypeLabel(row.bound_ip) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Health" min-width="160">
<el-table-column label="健康状态" min-width="160">
<template #default="{ row }">
<div class="binding-health-cell">
<el-tag :type="statusTone(row)" round effect="dark">
{{ statusText(row) }}
</el-tag>
<span class="muted">{{ row.status_label }}</span>
<span class="muted">{{ row.status === 1 ? '活动绑定' : '禁止放行' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Last activity" min-width="210">
<el-table-column label="最近活动" min-width="210">
<template #default="{ row }">
<div class="binding-activity-cell">
<strong>{{ formatLastSeen(row.last_used_at) }}</strong>
@@ -475,30 +473,30 @@ watch(
</template>
</el-table-column>
<el-table-column label="Actions" min-width="360" fixed="right">
<el-table-column label="操作" min-width="360" fixed="right">
<template #default="{ row }">
<div class="binding-action-row">
<el-button :icon="EditPen" @click="openEdit(row)">Edit CIDR</el-button>
<el-button :icon="EditPen" @click="openEdit(row)">编辑 CIDR</el-button>
<el-button
:icon="row.status === 1 ? Lock : Unlock"
:type="row.status === 1 ? 'warning' : 'success'"
plain
@click="
confirmAction(
row.status === 1 ? 'Ban this token?' : 'Restore this token to active state?',
row.status === 1 ? '确认封禁这个 Token 吗?' : '确认恢复这个 Token 吗?',
() => (row.status === 1 ? banBinding(row.id) : unbanBinding(row.id)),
)
"
>
{{ row.status === 1 ? 'Ban' : 'Restore' }}
{{ row.status === 1 ? '封禁' : '恢复' }}
</el-button>
<el-button
:icon="SwitchButton"
type="danger"
plain
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
@click="confirmAction('确认解绑并允许下次重新首绑吗?', () => unbindBinding(row.id))"
>
Unbind
解绑
</el-button>
</div>
</template>
@@ -507,7 +505,7 @@ watch(
</div>
<div class="toolbar pagination-toolbar">
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
<span class="muted"> {{ filters.page }} / {{ pageCount }} </span>
<el-pagination
background
layout="total, prev, pager, next"
@@ -519,9 +517,9 @@ watch(
</div>
</section>
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
<el-dialog v-model="dialogVisible" title="更新绑定地址" width="420px">
<el-form label-position="top">
<el-form-item label="CIDR or single IP">
<el-form-item label="CIDR 或单个 IP">
<el-input
v-model="form.bound_ip"
autocomplete="off"
@@ -533,8 +531,8 @@ watch(
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="submitEdit">Save</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
</div>

View File

@@ -45,8 +45,8 @@ async function renderChart() {
chart ||= echarts.init(chartElement.value)
chart.setOption({
animationDuration: 500,
color: ['#0b9e88', '#ef7f41'],
animationDuration: 400,
color: ['#4d8ff7', '#f29a44'],
grid: {
left: 24,
right: 24,
@@ -57,13 +57,13 @@ async function renderChart() {
legend: {
top: 0,
textStyle: {
color: '#516a75',
color: '#5f7893',
fontWeight: 600,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(8, 24, 34, 0.9)',
backgroundColor: 'rgba(34, 67, 108, 0.92)',
borderWidth: 0,
textStyle: {
color: '#f7fffe',
@@ -73,30 +73,30 @@ async function renderChart() {
type: 'category',
boundaryGap: false,
data: dashboard.value.trend.map((item) => item.date.slice(5)),
axisLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.18)' } },
axisLabel: { color: '#516a75', fontWeight: 600 },
axisLine: { lineStyle: { color: 'rgba(39, 74, 110, 0.16)' } },
axisLabel: { color: '#5f7893', fontWeight: 600 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.08)' } },
axisLabel: { color: '#516a75' },
splitLine: { lineStyle: { color: 'rgba(39, 74, 110, 0.08)' } },
axisLabel: { color: '#5f7893' },
},
series: [
{
name: 'Allowed',
name: '放行',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { color: 'rgba(11, 158, 136, 0.14)' },
areaStyle: { color: 'rgba(77, 143, 247, 0.14)' },
lineStyle: { width: 3 },
data: dashboard.value.trend.map((item) => item.allowed),
},
{
name: 'Intercepted',
name: '拦截',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { color: 'rgba(239, 127, 65, 0.12)' },
areaStyle: { color: 'rgba(242, 154, 68, 0.12)' },
lineStyle: { width: 3 },
data: dashboard.value.trend.map((item) => item.intercepted),
},
@@ -108,7 +108,7 @@ async function loadDashboard() {
await run(async () => {
dashboard.value = await fetchDashboard()
await renderChart()
}, 'Failed to load dashboard.')
}, '加载看板失败。')
}
async function refreshDashboard() {
@@ -139,51 +139,51 @@ onBeforeUnmount(() => {
<template>
<div class="page-grid">
<PageHero
eyebrow="Traffic pulse"
title="Edge decisions and security drift in one pass"
description="The dashboard combines live proxy metrics with persisted intercept records so security events remain visible even if Redis rolls over."
eyebrow="运行概览"
title="在一个页面里查看放行、拦截与绑定状态"
description="看板汇总今日代理结果、绑定规模和最近拦截记录,便于快速判断系统是否稳定运行。"
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Intercept rate</span>
<span class="eyebrow">拦截率</span>
<strong>{{ formatPercent(interceptRate) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Active share</span>
<span class="eyebrow">活跃占比</span>
<strong>{{ formatPercent(bindingCoverage) }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button :loading="loading" type="primary" plain @click="refreshDashboard">Refresh Dashboard</el-button>
<el-button :loading="loading" type="primary" plain @click="refreshDashboard">刷新看板</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Today"
eyebrow="今日总量"
:value="formatCompactNumber(dashboard.today.total)"
note="Total edge decisions recorded today."
note="今天经过网关处理的请求总数。"
accent="slate"
/>
<MetricTile
eyebrow="Allowed"
eyebrow="放行请求"
:value="formatCompactNumber(dashboard.today.allowed)"
note="Requests that passed binding enforcement."
accent="mint"
note="通过绑定校验并成功转发的请求。"
accent="slate"
/>
<MetricTile
eyebrow="Intercepted"
eyebrow="拦截请求"
:value="formatCompactNumber(dashboard.today.intercepted)"
note="Requests blocked for CIDR mismatch or banned keys."
note="因 IP 不匹配或 Token 被封禁而拦截。"
accent="amber"
/>
<MetricTile
eyebrow="Bindings"
eyebrow="当前绑定"
:value="formatCompactNumber(dashboard.bindings.active)"
:note="`Active bindings, with ${formatCompactNumber(dashboard.bindings.banned)} banned keys in reserve.`"
:note="`活跃绑定 ${formatCompactNumber(dashboard.bindings.active)} 条,封禁 ${formatCompactNumber(dashboard.bindings.banned)} 条。`"
accent="slate"
/>
</section>
@@ -192,24 +192,24 @@ onBeforeUnmount(() => {
<article class="chart-card panel">
<div class="toolbar">
<div>
<p class="eyebrow">7-day trend</p>
<h3 class="section-title">Allowed vs intercepted flow</h3>
<p class="eyebrow">7 日趋势</p>
<h3 class="section-title"> 7 天放行与拦截趋势</h3>
</div>
<div class="inline-meta">
<el-tag round effect="plain" type="success">30s auto refresh</el-tag>
<span class="muted">Redis metrics with PostgreSQL intercept backfill.</span>
<el-tag round effect="plain" type="primary">30 秒自动刷新</el-tag>
<span class="muted">结合 Redis 指标与 PostgreSQL 日志统计</span>
</div>
</div>
<div ref="chartElement" class="chart-surface" />
<div class="trend-summary">
<p class="eyebrow">Trend table</p>
<p class="eyebrow">趋势明细</p>
<div class="trend-table-wrap">
<table class="trend-table">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Allowed</th>
<th scope="col">Intercepted</th>
<th scope="col">日期</th>
<th scope="col">放行</th>
<th scope="col">拦截</th>
</tr>
</thead>
<tbody>
@@ -226,12 +226,12 @@ onBeforeUnmount(() => {
<article class="table-card panel">
<div class="table-toolbar-block">
<p class="eyebrow">Recent blocks</p>
<h3 class="section-title">Latest intercepted requests</h3>
<p class="muted">Operators can triage repeated misuse and verify whether alert escalation has already fired.</p>
<p class="eyebrow">最新事件</p>
<h3 class="section-title">最近拦截记录</h3>
<p class="muted">用于快速确认异常来源告警状态和是否需要进一步处置</p>
</div>
<div v-if="!dashboard.recent_intercepts.length" class="empty-state">No intercepts recorded yet.</div>
<div v-if="!dashboard.recent_intercepts.length" class="empty-state">当前还没有拦截记录</div>
<div v-else class="table-stack table-stack--spaced">
<article
@@ -242,11 +242,11 @@ onBeforeUnmount(() => {
<div class="toolbar">
<strong>{{ item.token_display }}</strong>
<el-tag :type="item.alerted ? 'danger' : 'warning'" round>
{{ item.alerted ? 'Alerted' : 'Pending' }}
{{ item.alerted ? '已告警' : '待观察' }}
</el-tag>
</div>
<p class="insight-note">Bound CIDR: {{ item.bound_ip }}</p>
<p class="insight-note">Attempt IP: {{ item.attempt_ip }}</p>
<p class="insight-note">绑定地址{{ item.bound_ip }}</p>
<p class="insight-note">尝试地址{{ item.attempt_ip }}</p>
<p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p>
</article>
</div>

View File

@@ -13,33 +13,33 @@ const form = reactive({
const { clearError, errorMessage, loading, run } = useAsyncAction()
const loginSignals = [
{
eyebrow: 'Proxy path',
title: 'Streaming request relay',
note: 'Headers and body pass through to the downstream API without buffering full model responses.',
eyebrow: '代理链路',
title: '流式请求透传',
note: '请求头与响应体直接转发到下游服务,兼容流式返回。',
},
{
eyebrow: 'Key policy',
title: 'First-use IP binding',
note: 'Every bearer token is pinned to a trusted client IP or CIDR on its first successful call.',
eyebrow: '绑定策略',
title: '首次使用自动绑定',
note: 'Bearer Token 首次成功调用时绑定来源 IP CIDR,后续持续校验。',
},
{
eyebrow: 'Operator safety',
title: 'JWT + lockout',
note: 'Admin login is rate-limited by source IP and issues an 8-hour signed token on success.',
eyebrow: '后台安全',
title: 'JWT 与限流保护',
note: '管理端登录按来源 IP 限流,成功后签发 8 小时令牌。',
},
]
async function submit() {
if (!form.password) {
ElMessage.warning('Enter the admin password first.')
ElMessage.warning('请先输入管理员密码。')
return
}
try {
clearError()
const data = await run(() => login(form.password), 'Login failed.')
const data = await run(() => login(form.password), '登录失败。')
setAuthToken(data.access_token)
ElMessage.success('Authentication complete.')
ElMessage.success('登录成功。')
await router.push({ name: 'dashboard' })
} catch {}
}
@@ -49,11 +49,10 @@ async function submit() {
<div class="login-shell">
<section class="login-stage panel">
<div class="login-stage-copy">
<p class="eyebrow">Edge enforcement</p>
<p class="eyebrow">边界网关</p>
<h1>Key-IP Sentinel</h1>
<p class="login-copy">
Lock every model API key to its first trusted origin. Monitor drift, inspect misuse, and react from one
hardened control surface.
将每个模型 API Key 固定到首次可信来源地址在一个后台里完成绑定查看拦截与处置
</p>
</div>
@@ -68,23 +67,23 @@ async function submit() {
<div class="stack">
<div class="status-chip status-chip--strong">
<el-icon><Lock /></el-icon>
Zero-trust perimeter
首次使用 IP 绑定
</div>
<div class="status-chip">
<el-icon><Connection /></el-icon>
Live downstream relay
下游请求实时透传
</div>
</div>
</section>
<section class="login-card">
<div class="login-card-inner">
<p class="eyebrow">Admin access</p>
<h2 class="section-title">Secure Operator Login</h2>
<p class="muted">Use the runtime password from your deployment environment to obtain an 8-hour admin token.</p>
<p class="eyebrow">管理员入口</p>
<h2 class="section-title">登录控制台</h2>
<p class="muted">使用部署环境中的管理员密码登录系统会签发 8 小时后台访问令牌</p>
<el-form label-position="top" @submit.prevent="submit">
<el-form-item label="Admin password">
<el-form-item label="管理员密码">
<el-input
v-model="form.password"
:aria-describedby="errorMessage ? 'login-error' : undefined"
@@ -93,7 +92,7 @@ async function submit() {
size="large"
autocomplete="current-password"
name="admin_password"
placeholder="Enter deployment password"
placeholder="请输入部署密码"
@input="clearError"
/>
</el-form-item>
@@ -101,15 +100,15 @@ async function submit() {
<p v-if="errorMessage" id="login-error" class="form-feedback" role="alert">{{ errorMessage }}</p>
<el-button native-type="submit" type="primary" size="large" :loading="loading" class="w-full">
Enter Control Plane
进入控制台
</el-button>
</el-form>
<div class="login-divider" />
<div class="login-footer-note">
<span class="eyebrow">Security note</span>
<p>Failed admin attempts are rate-limited by client IP before a JWT is issued.</p>
<span class="eyebrow">安全提示</span>
<p>后台登录失败会按客户端 IP 限流避免暴力尝试</p>
</div>
</div>
</section>
@@ -128,34 +127,35 @@ async function submit() {
.login-signal-grid {
display: grid;
gap: 14px;
gap: 12px;
}
.login-signal-card {
padding: 18px 20px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 16px 18px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.08));
border: 1px solid rgba(255, 255, 255, 0.18);
}
.login-signal-card h3 {
margin: 10px 0 8px;
font-size: 1.08rem;
margin: 8px 0 6px;
font-size: 1rem;
}
.login-signal-card p:last-child {
margin: 0;
color: rgba(247, 255, 254, 0.78);
color: rgba(247, 255, 254, 0.82);
font-size: 0.92rem;
}
.status-chip--strong {
background: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.22);
}
.login-divider {
height: 1px;
margin: 24px 0 18px;
background: linear-gradient(90deg, rgba(9, 22, 30, 0.06), rgba(11, 158, 136, 0.28), rgba(9, 22, 30, 0.06));
margin: 22px 0 16px;
background: linear-gradient(90deg, rgba(23, 50, 77, 0.05), rgba(77, 143, 247, 0.28), rgba(23, 50, 77, 0.05));
}
.login-footer-note p {

View File

@@ -2,7 +2,6 @@
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { useAsyncAction } from '../composables/useAsyncAction'
import { exportLogs, fetchLogs } from '../api'
@@ -25,20 +24,7 @@ const { loading: exporting, run: runExport } = useAsyncAction()
const alertedCount = computed(() => rows.value.filter((item) => item.alerted).length)
const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size)
const pendingCount = computed(() => rows.value.length - alertedCount.value)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
const intelCards = [
{
eyebrow: 'Escalation',
title: 'Alerted rows',
note: 'Rows marked alerted already crossed the Redis threshold window and were included in a webhook escalation.',
},
{
eyebrow: 'Forensics',
title: 'Attempt IP review',
note: 'Correlate repeated attempt IPs with internal NAT ranges or unknown external addresses before acting on the token.',
},
]
function parsePositiveInteger(value, fallbackValue) {
const parsed = Number.parseInt(value, 10)
@@ -107,7 +93,7 @@ async function loadLogs() {
const data = await fetchLogs(requestParams())
rows.value = data.items
total.value = data.total
}, 'Failed to load logs.')
}, '加载日志失败。')
}
async function refreshLogs() {
@@ -136,7 +122,7 @@ async function handleExport() {
start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined,
}),
'Failed to export logs.',
'导出日志失败。',
)
downloadBlob(blob, 'sentinel-logs.csv')
} catch {}
@@ -173,61 +159,34 @@ watch(
<template>
<div class="page-grid">
<PageHero
eyebrow="Audit trail"
title="Review blocked requests, escalation state, and repeated misuse patterns"
description="Intercept records stay in PostgreSQL even if Redis counters reset, so operators can reconstruct activity across the full retention window."
eyebrow="审计追踪"
title="查看拦截记录、来源地址和告警状态"
description="所有拦截结果都会落库保存便于按时间、Token 和尝试来源地址进行回溯。"
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Alerted on page</span>
<span class="eyebrow">已告警</span>
<strong>{{ formatCompactNumber(alertedCount) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Unique IPs</span>
<span class="eyebrow">来源地址</span>
<strong>{{ formatCompactNumber(uniqueAttempts) }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button type="primary" plain :loading="exporting" @click="handleExport">Export CSV</el-button>
<el-button type="primary" plain :loading="exporting" @click="handleExport">导出 CSV</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Page rows"
:value="formatCompactNumber(rows.length)"
note="Intercept rows visible in the current result page."
accent="slate"
/>
<MetricTile
eyebrow="Matching total"
:value="formatCompactNumber(total)"
note="Persisted intercepts matching the active filters."
accent="amber"
/>
<MetricTile
eyebrow="Alerted"
:value="formatCompactNumber(alertedCount)"
note="Visible rows that already triggered webhook escalation."
accent="amber"
/>
<MetricTile
eyebrow="Attempt sources"
:value="formatCompactNumber(uniqueAttempts)"
note="Distinct attempt IPs visible on the current page."
accent="mint"
/>
</section>
<section class="content-grid content-grid--balanced">
<section class="content-grid">
<article class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<div class="filter-field field-sm">
<label class="filter-label" for="log-token-filter">Masked Token</label>
<label class="filter-label" for="log-token-filter">脱敏 Token</label>
<el-input
id="log-token-filter"
v-model="filters.token"
@@ -235,12 +194,12 @@ watch(
autocomplete="off"
clearable
name="log_token_filter"
placeholder="Masked token..."
placeholder="输入脱敏 Token"
@keyup.enter="searchLogs"
/>
</div>
<div class="filter-field field-sm">
<label class="filter-label" for="log-attempt-ip-filter">Attempt IP</label>
<label class="filter-label" for="log-attempt-ip-filter">尝试来源 IP</label>
<el-input
id="log-attempt-ip-filter"
v-model="filters.attempt_ip"
@@ -248,43 +207,43 @@ watch(
autocomplete="off"
clearable
name="log_attempt_ip_filter"
placeholder="10.0.0.8..."
placeholder="例如 10.0.0.8"
@keyup.enter="searchLogs"
/>
</div>
<div class="filter-field field-range">
<label class="filter-label" for="log-time-range">Time Range</label>
<label class="filter-label" for="log-time-range">时间范围</label>
<el-date-picker
id="log-time-range"
v-model="filters.time_range"
aria-label="Filter by intercepted time range"
type="datetimerange"
range-separator="to"
start-placeholder="Start Time"
end-placeholder="End Time"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
</div>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset Filters</el-button>
<el-button type="primary" :loading="loading" @click="searchLogs">Search Logs</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :loading="loading" @click="searchLogs">查询日志</el-button>
</div>
</div>
<div class="data-table table-block">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="intercepted_at" label="Time" min-width="190">
<el-table-column prop="intercepted_at" label="时间" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template>
</el-table-column>
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="170" />
<el-table-column prop="attempt_ip" label="Attempt IP" min-width="160" />
<el-table-column label="Alerted" width="120">
<el-table-column prop="bound_ip" label="绑定地址" min-width="170" />
<el-table-column prop="attempt_ip" label="尝试地址" min-width="160" />
<el-table-column label="告警状态" width="120">
<template #default="{ row }">
<el-tag :type="row.alerted ? 'danger' : 'info'" round>
{{ row.alerted ? 'Yes' : 'No' }}
{{ row.alerted ? '已告警' : '未告警' }}
</el-tag>
</template>
</el-table-column>
@@ -292,7 +251,7 @@ watch(
</div>
<div class="toolbar pagination-toolbar">
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
<span class="muted"> {{ filters.page }} / {{ pageCount }} </span>
<el-pagination
background
layout="prev, pager, next"
@@ -303,27 +262,6 @@ watch(
/>
</div>
</article>
<aside class="soft-grid">
<article class="support-card">
<p class="eyebrow">On this page</p>
<div class="support-kpi">
<strong>{{ formatCompactNumber(pendingCount) }}</strong>
<p>Visible rows still below the escalation threshold or not yet marked as alerted.</p>
</div>
</article>
<article class="support-card">
<p class="eyebrow">Incident review</p>
<ul class="support-list" role="list">
<li v-for="item in intelCards" :key="item.title" class="support-list-item">
<span class="eyebrow">{{ item.eyebrow }}</span>
<strong>{{ item.title }}</strong>
<span class="muted">{{ item.note }}</span>
</li>
</ul>
</article>
</aside>
</section>
</div>
</template>

View File

@@ -21,19 +21,20 @@ const { loading, run } = useAsyncAction()
const { loading: saving, run: runSave } = useAsyncAction()
const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60))
const webhookState = computed(() => (form.alert_webhook_url ? 'Configured' : 'Disabled'))
const webhookState = computed(() => (form.alert_webhook_url ? '已配置' : '未启用'))
const failsafeLabel = computed(() => (form.failsafe_mode === 'closed' ? '安全优先' : '连续性优先'))
const hasUnsavedChanges = computed(() => Boolean(initialSnapshot.value) && buildSnapshot() !== initialSnapshot.value)
const modeCards = computed(() => [
{
eyebrow: 'Closed mode',
title: 'Protect the perimeter',
note: 'Reject traffic if Redis and PostgreSQL are both unavailable. Choose this when abuse prevention has priority over service continuity.',
eyebrow: 'Closed 模式',
title: '优先保证安全',
note: ' Redis PostgreSQL 都不可用时,直接拒绝请求。适合安全优先的生产环境。',
active: form.failsafe_mode === 'closed',
},
{
eyebrow: 'Open mode',
title: 'Preserve business flow',
note: 'Allow traffic to continue when the full binding backend is down. Choose this only when continuity requirements outweigh policy enforcement.',
eyebrow: 'Open 模式',
title: '优先保证连续性',
note: '当绑定后端不可用时仍允许请求继续转发,仅在业务连续性优先时使用。',
active: form.failsafe_mode === 'open',
},
])
@@ -57,7 +58,7 @@ function confirmDiscardChanges() {
return true
}
return window.confirm('You have unsaved runtime settings. Leave this page and discard them?')
return window.confirm('当前设置尚未保存,确定离开并放弃修改吗?')
}
function handleBeforeUnload(event) {
@@ -78,7 +79,7 @@ async function loadSettings() {
form.archive_days = data.archive_days
form.failsafe_mode = data.failsafe_mode
syncSnapshot()
}, 'Failed to load runtime settings.')
}, '加载运行设置失败。')
}
async function saveSettings() {
@@ -92,10 +93,10 @@ async function saveSettings() {
archive_days: form.archive_days,
failsafe_mode: form.failsafe_mode,
}),
'Failed to update runtime settings.',
'更新运行设置失败。',
)
syncSnapshot()
ElMessage.success('Runtime settings updated.')
ElMessage.success('运行设置已更新。')
} catch {}
}
@@ -114,15 +115,15 @@ onBeforeRouteLeave(() => confirmDiscardChanges())
<template>
<div class="page-grid">
<PageHero
eyebrow="Runtime controls"
title="Adjust alerting and retention without redeploying the app"
description="These values are persisted in Redis and applied live by the proxy, alerting, and archive scheduler services."
eyebrow="运行配置"
title="在线调整告警、归档与故障处理策略"
description="这些配置会写入 Redis 并实时生效,无需重新部署服务。"
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Failsafe</span>
<strong>{{ form.failsafe_mode }}</strong>
<span class="eyebrow">故障策略</span>
<strong>{{ failsafeLabel }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Webhook</span>
@@ -133,81 +134,81 @@ onBeforeRouteLeave(() => confirmDiscardChanges())
<template #actions>
<el-button type="primary" :disabled="loading || !hasUnsavedChanges" :loading="saving" @click="saveSettings">
Save Runtime Settings
保存设置
</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Threshold count"
eyebrow="告警阈值"
:value="formatCompactNumber(form.alert_threshold_count)"
note="Intercepts needed before alert escalation fires."
note="达到该拦截次数后触发告警。"
accent="amber"
/>
<MetricTile
eyebrow="Threshold window"
eyebrow="统计窗口"
:value="`${formatCompactNumber(thresholdMinutes)}m`"
note="Rolling window used by the Redis alert counter."
note="Redis 统计告警次数使用的时间窗口。"
accent="slate"
/>
<MetricTile
eyebrow="Archive after"
eyebrow="归档周期"
:value="`${formatCompactNumber(form.archive_days)}d`"
note="Bindings older than this are pruned from the active table."
accent="mint"
note="超过该时间未活跃的绑定将从活动表归档。"
accent="slate"
/>
<MetricTile
eyebrow="Delivery"
eyebrow="通知状态"
:value="webhookState"
note="Webhook POST is optional and can be disabled."
note="Webhook 可选配置,不影响核心代理功能。"
accent="slate"
/>
</section>
<section class="content-grid content-grid--balanced">
<article class="form-card panel">
<p class="eyebrow">Alert window</p>
<h3 class="section-title">Thresholds and Webhook Delivery</h3>
<p class="eyebrow">告警配置</p>
<h3 class="section-title">阈值与 Webhook 通知</h3>
<el-form label-position="top" v-loading="loading">
<el-form-item label="Webhook URL">
<el-form-item label="Webhook 地址">
<el-input
v-model="form.alert_webhook_url"
autocomplete="off"
name="alert_webhook_url"
placeholder="https://hooks.example.internal/sentinel..."
placeholder="https://hooks.example.internal/sentinel"
/>
</el-form-item>
<el-form-item label="Intercept count threshold">
<el-form-item label="拦截次数阈值">
<el-input-number v-model="form.alert_threshold_count" :min="1" :max="100" />
</el-form-item>
<el-form-item label="Threshold window (seconds)">
<el-form-item label="统计窗口(秒)">
<el-slider v-model="form.alert_threshold_seconds" :min="60" :max="3600" :step="30" show-input />
</el-form-item>
<el-form-item label="Failsafe mode">
<el-form-item label="故障处理模式">
<el-radio-group v-model="form.failsafe_mode">
<el-radio-button value="closed">Closed</el-radio-button>
<el-radio-button value="open">Open</el-radio-button>
<el-radio-button value="closed">安全优先</el-radio-button>
<el-radio-button value="open">连续性优先</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<p class="form-feedback form-feedback--status" role="status">
{{ hasUnsavedChanges ? 'You have unsaved runtime changes.' : 'Runtime settings are in sync.' }}
{{ hasUnsavedChanges ? '当前有未保存的修改。' : '当前运行设置已同步。' }}
</p>
</article>
<aside class="soft-grid">
<article class="form-card panel">
<p class="eyebrow">Retention</p>
<h3 class="section-title">Archive Stale Bindings</h3>
<p class="eyebrow">归档策略</p>
<h3 class="section-title">归档长期不活跃绑定</h3>
<el-form label-position="top" v-loading="loading">
<el-form-item label="Archive inactive bindings after N days">
<el-form-item label="超过 N 天未活跃后归档">
<el-slider v-model="form.archive_days" :min="7" :max="365" :step="1" show-input />
</el-form-item>
</el-form>