fix: address multiple code audit findings
- CORS: replace wildcard with explicit origin list from CORS_ORIGINS env - Auth: enforce strong defaults, JWT blacklist (RevokedToken model), login rate limiting - Auth: validate password length before bcrypt (72-byte limit) - Scheduler: single-threaded worker to mitigate SQLite write contention - Scheduler: graceful shutdown (wait=True) - Snapshots: add prune_snapshots() with configurable retention count - Storage: isolate localStorage keys via VITE_APP_KEY prefix - Config: add cors_origins, login_rate_limit, snapshot_retention_count settings
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="shell-page page-section upstreams-page">
|
||||
<div class="shell-page shell-page-fluid page-section upstreams-page">
|
||||
<section class="page-header upstreams-hero surface-card">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Monitoring Matrix</p>
|
||||
@@ -44,85 +44,166 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="surface-card data-stage">
|
||||
<div class="section-header data-stage-head">
|
||||
<div>
|
||||
<div class="section-caption">Upstream Registry</div>
|
||||
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
|
||||
<section class="upstreams-content">
|
||||
<section class="surface-card data-stage">
|
||||
<div class="section-header data-stage-head">
|
||||
<div>
|
||||
<div class="section-caption">Upstream Registry</div>
|
||||
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
|
||||
</div>
|
||||
<p class="data-stage-note">点击详情可查看快照历史、分组倍率与最近错误。</p>
|
||||
</div>
|
||||
<p class="data-stage-note">点击详情可查看快照历史、分组倍率与最近错误。</p>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
|
||||
<el-table-column label="来源" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-name">{{ row.name }}</div>
|
||||
<div class="cell-url mono">{{ row.base_url }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
|
||||
<el-table-column label="来源" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-name">{{ row.name }}</div>
|
||||
<div class="cell-url mono">{{ row.base_url }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="118">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-badge', row.last_status]">
|
||||
<span class="dot" />
|
||||
{{ statusLabel(row.last_status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="118">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-badge', row.last_status]">
|
||||
<span class="dot" />
|
||||
{{ statusLabel(row.last_status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="启用" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="认证" width="132">
|
||||
<template #default="{ row }">
|
||||
<span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="认证" width="132">
|
||||
<template #default="{ row }">
|
||||
<span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="检测间隔" width="112">
|
||||
<template #default="{ row }">
|
||||
<span class="mono time-inline">{{ row.check_interval_seconds }}s</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检测间隔" width="112">
|
||||
<template #default="{ row }">
|
||||
<span class="mono time-inline">{{ row.check_interval_seconds }}s</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="最近检测" min-width="168">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span>
|
||||
<span v-else class="muted">未检测</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近检测" min-width="168">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span>
|
||||
<span v-else class="muted">未检测</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="最近错误" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
|
||||
<span class="error-text">{{ shrinkError(row.last_error) }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="muted">无异常</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近错误" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
|
||||
<span class="error-text">{{ shrinkError(row.last_error) }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="muted">无异常</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="258" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-row">
|
||||
<el-button size="small" text @click="openEdit(row)" title="编辑">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
||||
<el-button size="small" text type="info" @click="openDetail(row)">
|
||||
<el-icon><List /></el-icon>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
<el-table-column label="操作" width="258" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-row">
|
||||
<el-button size="small" text @click="openEdit(row)" title="编辑">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
||||
<el-button size="small" text type="info" @click="openDetail(row)">
|
||||
<el-icon><List /></el-icon>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<aside class="upstreams-side surface-card">
|
||||
<section class="side-section overview-section">
|
||||
<div class="section-header insight-head">
|
||||
<div>
|
||||
<div class="section-caption">Runtime Snapshot</div>
|
||||
<h3 class="insight-title">运行概览</h3>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<span class="insight-pill">健康率 {{ healthyRate }}%</span>
|
||||
</div>
|
||||
<div class="insight-grid">
|
||||
<div class="insight-metric">
|
||||
<span>异常节点</span>
|
||||
<strong>{{ metrics.unhealthy }}</strong>
|
||||
</div>
|
||||
<div class="insight-metric">
|
||||
<span>已启用</span>
|
||||
<strong>{{ metrics.enabled }}</strong>
|
||||
</div>
|
||||
<div class="insight-metric">
|
||||
<span>待检测</span>
|
||||
<strong>{{ pendingChecks }}</strong>
|
||||
</div>
|
||||
<div class="insight-metric">
|
||||
<span>最近检测</span>
|
||||
<strong>{{ latestCheckedAt ? fmtTime(latestCheckedAt) : '—' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="side-section">
|
||||
<div class="section-header insight-head compact">
|
||||
<div>
|
||||
<div class="section-caption">Need Attention</div>
|
||||
<h3 class="insight-title">待关注节点</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="attentionList.length > 0" class="feed-list">
|
||||
<button
|
||||
v-for="row in attentionList"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
class="feed-item"
|
||||
@click="openDetail(row)"
|
||||
>
|
||||
<div class="feed-main">
|
||||
<div class="feed-top">
|
||||
<span class="feed-name">{{ row.name }}</span>
|
||||
<span :class="['status-badge', row.last_status]">
|
||||
<span class="dot" />{{ statusLabel(row.last_status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="feed-meta">{{ row.last_error ? shrinkError(row.last_error) : '最近状态异常,建议查看快照详情' }}</div>
|
||||
</div>
|
||||
<span class="feed-link">详情</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-hint side-empty">当前没有需要关注的异常节点</div>
|
||||
</section>
|
||||
|
||||
<section class="side-section">
|
||||
<div class="section-header insight-head compact">
|
||||
<div>
|
||||
<div class="section-caption">Recent Activity</div>
|
||||
<h3 class="insight-title">最近检测</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recentChecks.length > 0" class="timeline-list">
|
||||
<div v-for="row in recentChecks" :key="row.id" class="timeline-item">
|
||||
<div class="timeline-main">
|
||||
<div class="timeline-name">{{ row.name }}</div>
|
||||
<div class="timeline-meta mono">{{ fmtTime(row.last_checked_at!) }}</div>
|
||||
</div>
|
||||
<el-button size="small" text type="primary" @click="openDetail(row)">查看</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint side-empty">还没有检测记录</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<el-drawer
|
||||
@@ -382,6 +463,34 @@ const metrics = computed(() => ({
|
||||
unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length,
|
||||
}))
|
||||
|
||||
const healthyRate = computed(() => {
|
||||
if (metrics.value.total === 0) return 0
|
||||
return Math.round((metrics.value.healthy / metrics.value.total) * 100)
|
||||
})
|
||||
|
||||
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
|
||||
|
||||
const latestCheckedAt = computed(() => {
|
||||
const times = list.value
|
||||
.map((item) => item.last_checked_at)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort((a, b) => new Date(toUTC(b)).getTime() - new Date(toUTC(a)).getTime())
|
||||
return times[0] || ''
|
||||
})
|
||||
|
||||
const attentionList = computed(() =>
|
||||
list.value
|
||||
.filter((item) => item.last_status === 'unhealthy' || item.last_error)
|
||||
.slice(0, 4),
|
||||
)
|
||||
|
||||
const recentChecks = computed(() =>
|
||||
[...list.value]
|
||||
.filter((item) => Boolean(item.last_checked_at))
|
||||
.sort((a, b) => new Date(toUTC(b.last_checked_at as string)).getTime() - new Date(toUTC(a.last_checked_at as string)).getTime())
|
||||
.slice(0, 5),
|
||||
)
|
||||
|
||||
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
||||
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
|
||||
@@ -468,6 +577,7 @@ async function testUpstream(row: any) {
|
||||
const res = await upstreamsApi.test(row.id)
|
||||
if (res.data.success) ElMessage.success(res.data.message)
|
||||
else ElMessage.error(res.data.detail || res.data.message)
|
||||
await loadList()
|
||||
} finally {
|
||||
row._testing = false
|
||||
}
|
||||
@@ -544,7 +654,9 @@ onMounted(loadList)
|
||||
}
|
||||
|
||||
.upstreams-hero {
|
||||
padding: 1.35rem;
|
||||
align-items: center;
|
||||
padding: 1.2rem 1.25rem;
|
||||
min-height: 8.7rem;
|
||||
border-radius: var(--radius-shell);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(217, 139, 66, 0.14), transparent 24%),
|
||||
@@ -552,15 +664,43 @@ onMounted(loadList)
|
||||
var(--bg-panel);
|
||||
}
|
||||
|
||||
.upstreams-hero .page-heading {
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.upstreams-hero .page-title {
|
||||
font-size: clamp(1.95rem, 1.45rem + 1.2vw, 2.65rem);
|
||||
}
|
||||
|
||||
.upstreams-hero .page-desc {
|
||||
max-width: 50rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.upstreams-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.9fr) minmax(360px, 0.82fr);
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.data-stage {
|
||||
padding: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-stage,
|
||||
.upstreams-side {
|
||||
min-height: 33rem;
|
||||
}
|
||||
|
||||
.data-stage-head {
|
||||
min-height: 4.65rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -576,6 +716,149 @@ onMounted(loadList)
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.upstreams-side {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
padding: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.side-section {
|
||||
padding-block: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.side-section:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.side-section:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
min-height: 12.4rem;
|
||||
}
|
||||
|
||||
.insight-head {
|
||||
min-height: 3.75rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.insight-head.compact {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.insight-title {
|
||||
margin: 0.24rem 0 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.insight-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.38rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(239, 175, 99, 0.12);
|
||||
border: 1px solid rgba(239, 175, 99, 0.18);
|
||||
color: var(--color-primary-strong);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.insight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.insight-metric {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: var(--radius-control);
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
border: 1px solid rgba(255, 244, 232, 0.06);
|
||||
}
|
||||
|
||||
.insight-metric span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.insight-metric strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.feed-list,
|
||||
.timeline-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.feed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
width: 100%;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border: 1px solid rgba(255, 244, 232, 0.06);
|
||||
border-radius: var(--radius-control);
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feed-item:hover {
|
||||
border-color: rgba(239, 175, 99, 0.2);
|
||||
background: rgba(255, 244, 232, 0.04);
|
||||
}
|
||||
|
||||
.feed-main,
|
||||
.timeline-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.feed-top,
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.feed-name,
|
||||
.timeline-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.feed-meta,
|
||||
.timeline-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.feed-link {
|
||||
color: var(--color-primary-strong);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.side-empty {
|
||||
min-height: auto;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
|
||||
.auth-badge {
|
||||
background: rgba(134, 183, 199, 0.12);
|
||||
color: var(--color-info);
|
||||
@@ -714,6 +997,12 @@ onMounted(loadList)
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.upstreams-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.info-cards {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -722,10 +1011,16 @@ onMounted(loadList)
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.upstreams-hero,
|
||||
.data-stage {
|
||||
.data-stage,
|
||||
.upstreams-side {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.data-stage,
|
||||
.upstreams-side {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -733,5 +1028,14 @@ onMounted(loadList)
|
||||
.hero-actions :deep(.el-button) {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.insight-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.feed-item,
|
||||
.timeline-item {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user