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:
SmartUp Developer
2026-05-17 10:52:18 +08:00
parent a42ecf7bcc
commit ad16618406
25 changed files with 792 additions and 165 deletions
+4 -2
View File
@@ -1,5 +1,6 @@
import axios from 'axios'
import router from '@/router'
import { authStorageKeys } from '@/authStorage'
export const api = axios.create({
baseURL: '/',
@@ -10,8 +11,8 @@ api.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('smartup_token')
localStorage.removeItem('smartup_email')
localStorage.removeItem(authStorageKeys.token)
localStorage.removeItem(authStorageKeys.email)
router.push('/login')
}
return Promise.reject(err)
@@ -293,6 +294,7 @@ export const browserSessionsApi = {
get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`),
event: (id: string, data: BrowserEventPayload) =>
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
screenshotUrl: (id: string, token?: string) => {
const params = new URLSearchParams({ t: String(Date.now()) })
+5
View File
@@ -91,6 +91,11 @@ img {
margin: 0 auto;
}
.shell-page.shell-page-fluid {
width: 100%;
max-width: none;
}
.page-section {
display: grid;
gap: 1.25rem;
+7
View File
@@ -0,0 +1,7 @@
const appKey = import.meta.env.VITE_APP_KEY || location.pathname.replace(/\W+/g, '_') || 'smartup'
const prefix = `smartup_${appKey}`
export const authStorageKeys = {
token: `${prefix}_token`,
email: `${prefix}_email`,
}
+7 -12
View File
@@ -19,13 +19,6 @@
<div class="sidebar-section">
<div class="sidebar-section-title">监控中枢</div>
<nav class="sidebar-nav">
<router-link to="/upstreams" class="nav-item" active-class="active" @click="closeMobileNav">
<span class="nav-icon"><el-icon><Connection /></el-icon></span>
<span class="nav-copy">
<strong>上游管理</strong>
<small>轮询健康度倍率快照</small>
</span>
</router-link>
<router-link to="/websites" class="nav-item" active-class="active" @click="closeMobileNav">
<span class="nav-icon"><el-icon><OfficeBuilding /></el-icon></span>
<span class="nav-copy">
@@ -33,6 +26,13 @@
<small>目标站点分组映射自动同步</small>
</span>
</router-link>
<router-link to="/upstreams" class="nav-item" active-class="active" @click="closeMobileNav">
<span class="nav-icon"><el-icon><Connection /></el-icon></span>
<span class="nav-copy">
<strong>上游管理</strong>
<small>轮询健康度倍率快照</small>
</span>
</router-link>
<router-link to="/webhooks" class="nav-item" active-class="active" @click="closeMobileNav">
<span class="nav-icon"><el-icon><Bell /></el-icon></span>
<span class="nav-copy">
@@ -531,11 +531,6 @@ watch([() => route.path, customPages], () => {
background: rgba(25, 19, 16, 0.7);
}
.topbar:not(.compact) {
width: min(100%, var(--content-max));
margin: 0 auto;
}
.topbar.compact {
min-height: 2.8rem;
padding: 0.4rem 0.75rem;
+7 -6
View File
@@ -1,24 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/api'
import { authStorageKeys } from '@/authStorage'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(localStorage.getItem('smartup_token') || '')
const email = ref<string>(localStorage.getItem('smartup_email') || '')
const token = ref<string>(localStorage.getItem(authStorageKeys.token) || '')
const email = ref<string>(localStorage.getItem(authStorageKeys.email) || '')
function setToken(t: string, e: string) {
token.value = t
email.value = e
localStorage.setItem('smartup_token', t)
localStorage.setItem('smartup_email', e)
localStorage.setItem(authStorageKeys.token, t)
localStorage.setItem(authStorageKeys.email, e)
api.defaults.headers.common['Authorization'] = `Bearer ${t}`
}
function clear() {
token.value = ''
email.value = ''
localStorage.removeItem('smartup_token')
localStorage.removeItem('smartup_email')
localStorage.removeItem(authStorageKeys.token)
localStorage.removeItem(authStorageKeys.email)
delete api.defaults.headers.common['Authorization']
}
+1 -1
View File
@@ -100,7 +100,7 @@ const formRef = ref<FormInstance>()
const loading = ref(false)
const errorMsg = ref('')
const form = ref({ email: 'admin@smartup.local', password: 'changeme123' })
const form = ref({ email: '', password: '' })
const rules = {
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
+38 -12
View File
@@ -1,5 +1,5 @@
<template>
<div class="shell-page page-section">
<div class="shell-page shell-page-fluid page-section">
<div class="page-header surface-card page-block">
<div class="page-heading">
<p class="page-kicker">Delivery Trace</p>
@@ -7,11 +7,11 @@
<p class="page-desc">查看所有 Webhook 通知的发送记录</p>
</div>
<div class="filters">
<el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="loadList">
<el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="handleFilterChange">
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
<el-select v-model="filterEvent" placeholder="事件类型" clearable style="width:150px" @change="loadList">
<el-select v-model="filterEvent" placeholder="事件类型" clearable style="width:150px" @change="handleFilterChange">
<el-option label="上游倍率变更" value="upstream_rate_changed" />
<el-option label="网站倍率变更" value="website_rate_changed" />
<el-option label="服务异常" value="upstream_unhealthy" />
@@ -58,9 +58,13 @@
</el-table-column>
</el-table>
<div class="pagination">
<el-button :disabled="offset === 0" @click="prevPage" size="small">上一页</el-button>
<span class="page-info"> {{ offset / limit + 1 }} </span>
<el-button :disabled="list.length < limit" @click="nextPage" size="small">下一页</el-button>
<div class="page-info">
{{ currentPage }} · 每页 {{ pageSize }}
</div>
<div class="page-actions">
<el-button :disabled="offset === 0 || tableLoading" @click="prevPage" size="small">上一页</el-button>
<el-button :disabled="!hasNextPage || tableLoading" @click="nextPage" size="small">下一页</el-button>
</div>
</div>
</div>
@@ -91,7 +95,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import dayjs from 'dayjs'
import { logsApi, type LogData } from '@/api'
@@ -102,7 +106,9 @@ const detailRow = ref<LogData | null>(null)
const filterStatus = ref('')
const filterEvent = ref('')
const offset = ref(0)
const limit = 50
const pageSize = 50
const hasNextPage = ref(false)
const currentPage = computed(() => Math.floor(offset.value / pageSize) + 1)
const EVENT_LABELS: Record<string, string> = {
upstream_rate_changed: '上游倍率变更',
@@ -124,10 +130,11 @@ async function loadList() {
const res = await logsApi.list({
status: filterStatus.value || undefined,
event_type: filterEvent.value || undefined,
limit,
limit: pageSize + 1,
offset: offset.value,
})
list.value = res.data
hasNextPage.value = res.data.length > pageSize
list.value = res.data.slice(0, pageSize)
} finally {
tableLoading.value = false
}
@@ -138,12 +145,17 @@ function viewDetail(row: LogData) {
detailVisible.value = true
}
function handleFilterChange() {
offset.value = 0
loadList()
}
function prevPage() {
offset.value = Math.max(0, offset.value - limit)
offset.value = Math.max(0, offset.value - pageSize)
loadList()
}
function nextPage() {
offset.value += limit
offset.value += pageSize
loadList()
}
@@ -158,4 +170,18 @@ onMounted(loadList)
.page-header {
border-radius: var(--radius-shell);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
}
.page-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>
+103 -10
View File
@@ -18,6 +18,11 @@
<el-icon><Back /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="isRemoteBrowser" content="复制远程选中文本">
<el-button size="small" text @click="copyRemoteSelection">
<el-icon><DocumentCopy /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="isRemoteBrowser" content="前进">
<el-button size="small" text @click="sendRemoteCommand('forward')">
<el-icon><Right /></el-icon>
@@ -67,7 +72,7 @@
class="remote-screen"
alt=""
draggable="false"
@load="() => { iframeLoading = false }"
@load="onRemoteImageLoad"
@error="() => handleRemoteSessionFailure(undefined, '远程浏览器截图加载失败')"
@pointerdown.stop.prevent="onRemotePointerDown"
@pointermove.stop.prevent="onRemotePointerMove"
@@ -126,7 +131,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, markRaw } f
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning,
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy,
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue'
@@ -164,7 +169,12 @@ const isReconnectingRemoteBrowser = ref(false)
const remoteErrorState = ref<RemoteBrowserErrorState | null>(null)
let startRemoteBrowserPromise: Promise<void> | null = null
let screenshotObjectUrl = ''
let previousScreenshotObjectUrl = ''
let pendingScreenshotBlob: Blob | null = null
let screenshotFrameRequest: number | undefined
let mouseMoveTimer: number | undefined
let wheelTimer: number | undefined
let pendingWheel: { deltaX: number; deltaY: number; x: number; y: number } | null = null
let caretHideTimer: number | undefined
// Caret / cursor overlay
@@ -183,8 +193,11 @@ let wsReconnectTimer: number | undefined
let wsReconnectAttempts = 0
const WS_MAX_RECONNECT = 5
const WS_RECONNECT_BASE_MS = 800
const REMOTE_DRAG_MOVE_INTERVAL_MS = 16
const REMOTE_HOVER_MOVE_INTERVAL_MS = 80
const WS_BACKPRESSURE_BYTES = 256 * 1024
const REMOTE_DRAG_MOVE_INTERVAL_MS = 32
const REMOTE_HOVER_MOVE_INTERVAL_MS = 120
const REMOTE_WHEEL_INTERVAL_MS = 45
const HIGH_FREQUENCY_EVENTS = new Set(['mousemove', 'scroll'])
type RemoteBrowserErrorState = {
title: string
@@ -318,10 +331,7 @@ function connectRemoteWs() {
socket.onmessage = (evt) => {
if (evt.data instanceof Blob) {
// Binary frame = JPEG screenshot
const newUrl = URL.createObjectURL(evt.data)
setRemoteScreenshotUrl(newUrl)
iframeLoading.value = false
queueRemoteScreenshot(evt.data)
return
}
// Text frame = JSON control message
@@ -368,6 +378,11 @@ function stopRemoteWs() {
window.clearTimeout(wsReconnectTimer)
wsReconnectTimer = undefined
}
if (wheelTimer !== undefined) {
window.clearTimeout(wheelTimer)
wheelTimer = undefined
}
pendingWheel = null
if (ws) {
// Prevent onclose from triggering reconnect
const old = ws
@@ -380,11 +395,13 @@ function stopRemoteWs() {
async function sendRemoteEvent(payload: BrowserEventPayload) {
if (!props.active || isStartingRemoteBrowser.value) return
if (!remoteSession.value) return
const highFrequency = HIGH_FREQUENCY_EVENTS.has(payload.type)
if (ws && ws.readyState === WebSocket.OPEN) {
if (highFrequency && ws.bufferedAmount > WS_BACKPRESSURE_BYTES) return
ws.send(JSON.stringify(payload))
return
}
// Fallback: HTTP POST (e.g. during reconnect window)
if (highFrequency) return
try {
const res = await browserSessionsApi.event(remoteSession.value.id, payload)
remoteSession.value = res.data
@@ -397,6 +414,22 @@ function sendRemoteCommand(type: 'reload' | 'back' | 'forward') {
sendRemoteEvent({ type })
}
async function copyRemoteSelection() {
if (!remoteSession.value) return
try {
const res = await browserSessionsApi.selection(remoteSession.value.id)
const text = res.data.text.trim()
if (!text) {
ElMessage.warning('远程页面没有选中文本')
return
}
await navigator.clipboard.writeText(text)
ElMessage.success('已复制远程选中文本')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '复制失败')
}
}
function remoteViewport() {
const rect = remoteFrameRef.value?.getBoundingClientRect()
return {
@@ -513,7 +546,23 @@ function onRemotePointerCancel(event: PointerEvent) {
function onRemoteWheel(event: WheelEvent) {
const point = eventPoint(event)
if (!point) return
sendRemoteEvent({ type: 'scroll', delta_x: event.deltaX, delta_y: event.deltaY, ...point })
if (!pendingWheel) {
pendingWheel = { deltaX: 0, deltaY: 0, ...point }
}
pendingWheel.deltaX += event.deltaX
pendingWheel.deltaY += event.deltaY
pendingWheel.x = point.x
pendingWheel.y = point.y
if (wheelTimer !== undefined) return
wheelTimer = window.setTimeout(flushRemoteWheel, REMOTE_WHEEL_INTERVAL_MS)
}
function flushRemoteWheel() {
wheelTimer = undefined
const wheel = pendingWheel
pendingWheel = null
if (!wheel) return
sendRemoteEvent({ type: 'scroll', delta_x: wheel.deltaX, delta_y: wheel.deltaY, x: wheel.x, y: wheel.y })
}
function onRemoteKeydown(event: KeyboardEvent) {
@@ -521,6 +570,12 @@ function onRemoteKeydown(event: KeyboardEvent) {
// which we handle in onRemotePaste with the actual clipboard text.
const isPaste = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v'
if (isPaste) return
const isCopy = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c'
if (isCopy) {
event.preventDefault()
copyRemoteSelection()
return
}
// Prevent browser from handling other keys (scrolling, shortcuts, etc.)
event.preventDefault()
@@ -568,7 +623,45 @@ async function closeRemoteSession() {
await browserSessionsApi.close(id).catch(() => undefined)
}
function queueRemoteScreenshot(blob: Blob) {
pendingScreenshotBlob = blob
if (screenshotFrameRequest !== undefined) return
screenshotFrameRequest = window.requestAnimationFrame(renderPendingScreenshot)
}
function renderPendingScreenshot() {
screenshotFrameRequest = undefined
const blob = pendingScreenshotBlob
pendingScreenshotBlob = null
if (!blob) return
const nextUrl = URL.createObjectURL(blob)
if (previousScreenshotObjectUrl) URL.revokeObjectURL(previousScreenshotObjectUrl)
previousScreenshotObjectUrl = screenshotObjectUrl
screenshotObjectUrl = nextUrl
remoteScreenshotUrl.value = nextUrl
}
function onRemoteImageLoad() {
iframeLoading.value = false
if (previousScreenshotObjectUrl) {
URL.revokeObjectURL(previousScreenshotObjectUrl)
previousScreenshotObjectUrl = ''
}
if (pendingScreenshotBlob && screenshotFrameRequest === undefined) {
screenshotFrameRequest = window.requestAnimationFrame(renderPendingScreenshot)
}
}
function setRemoteScreenshotUrl(url: string) {
if (screenshotFrameRequest !== undefined) {
window.cancelAnimationFrame(screenshotFrameRequest)
screenshotFrameRequest = undefined
}
pendingScreenshotBlob = null
if (previousScreenshotObjectUrl) {
URL.revokeObjectURL(previousScreenshotObjectUrl)
previousScreenshotObjectUrl = ''
}
if (screenshotObjectUrl) {
URL.revokeObjectURL(screenshotObjectUrl)
screenshotObjectUrl = ''
+376 -72
View File
@@ -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>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="shell-page page-section">
<div class="shell-page shell-page-fluid page-section">
<div class="page-header surface-card page-block">
<div class="page-heading">
<p class="page-kicker">Delivery Mesh</p>
+8 -5
View File
@@ -1,5 +1,5 @@
<template>
<div class="shell-page page-section websites-page">
<div class="shell-page shell-page-fluid page-section websites-page">
<div class="page-header surface-card page-block">
<div class="page-heading">
<p class="page-kicker">Sync Orchestration</p>
@@ -44,7 +44,7 @@
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<el-table-column label="操作" width="174" align="right">
<template #default="{ row }">
<div class="action-row">
<el-tooltip content="查看分组" placement="top" :show-after="300">
@@ -637,12 +637,15 @@ onMounted(loadAll)
.action-row {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 4px;
gap: 2px;
min-width: 0;
}
.action-row .el-button.is-circle {
width: 28px;
height: 28px;
width: 26px;
height: 26px;
margin-left: 0;
}
.binding-actions {
display: flex;