Redesign admin UI with Chinese light theme
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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: '运行配置',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user