feat(servers): interactive stat cards and unset_path count

Click offline/online to filter, unset-path to scroll to panel; add unset_path to /servers/stats; fix Docker deploy frontend sync.
This commit is contained in:
Nexus Agent
2026-06-09 09:42:51 +08:00
parent c5e54da8de
commit cbbcdae66d
11 changed files with 256 additions and 14 deletions
+8 -1
View File
@@ -118,7 +118,14 @@ else
fi
if [[ "$RUNTIME" == "docker" ]]; then
echo "▶ Sync Git web/app into container..."
# Host web/app must match image build before sync_webapp_to_container (else stale assets overwrite container).
echo "▶ Frontend: local vite build + upload to host web/app..."
cd frontend && npx vite build && cd ..
tar czf /tmp/nexus-frontend.tar.gz -C web/app index.html assets/
scp_cmd /tmp/nexus-frontend.tar.gz "${NEXUS_SSH_HOST}:/tmp/nexus-frontend.tar.gz"
ssh_cmd "cd ${DEPLOY_PATH}/web/app && sudo tar xzf /tmp/nexus-frontend.tar.gz && rm /tmp/nexus-frontend.tar.gz"
echo "▶ Sync host web/app into container..."
ssh_cmd "sudo env NEXUS_ROOT=${DEPLOY_PATH} NEXUS_PUBLISH_PORT=\$(grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod 2>/dev/null | cut -d= -f2 | tr -d '\"' || echo 8600) bash ${DEPLOY_PATH}/deploy/sync_webapp_to_container.sh" || {
echo "WARN: web/app sync failed — run sync_webapp_to_container.sh on server manually" >&2
}
@@ -31,4 +31,4 @@
## DoD
- [x] changelog + audit
- [ ] 生产部署验证
- [x] 生产部署验证`c5e54da`/health ok/app 200
@@ -0,0 +1,32 @@
# 审计 — 服务器页顶部统计卡交互
**Changelog**: `docs/changelog/2026-06-09-servers-stat-card-filter.md` · `docs/changelog/2026-06-09-docker-frontend-sync-fix.md`
## 变更文件
| 文件 | 说明 |
|------|------|
| `StatCardsRow.vue` | 可点击/active 态;5 卡布局 |
| `useServerPagination.ts` | `is_online` 筛选 |
| `ServersPage.vue` | 统计卡交互;未设路径定位 |
| `ServerUnsetPathPanel.vue` | 高亮 id |
| `servers.py` | stats `unset_path` |
| `deploy-production.sh` | Docker 部署前本地 vite build |
| `test_servers_dashboard.py` | unset_path 断言 |
## Step 3
| 规则 | 结论 |
|------|------|
| 安全 | PASS — 只读 stats + 既有 list 筛选 |
| 行为 | PASS — 总数不可点;离线/在线/未设路径/告警可交互 |
## Closure
- `npm run type-check` PASS
- `pytest tests/integration/test_servers_dashboard.py -q` PASS
## DoD
- [x] changelog + audit
- [ ] 生产部署验证
@@ -0,0 +1,17 @@
# 2026-06-09 — Docker 部署前端同步修复
## 摘要
`deploy-production.sh` Docker 路径在 `sync_webapp_to_container` 前增加本地 `vite build` 并上传至宿主机 `web/app`,避免用 Git 中过期的静态资源覆盖镜像内新构建。
## 动机
Agent「诊断」按钮已进镜像,但部署后 sync 把宿主机旧 `ServersPage-*.js` 拷进容器,生产 UI 看不到新按钮。
## 涉及文件
- `deploy/deploy-production.sh`
## 验证
部署后容器内 `grep 诊断 ServersPage-*.js` 应 > 0。
@@ -0,0 +1,21 @@
# 2026-06-09 — 服务器页顶部统计卡可点击筛选
## 摘要
服务器页顶部统计卡可点击:离线/在线筛选列表,**未设路径**定位并高亮下方面板,告警跳转告警页;**服务器总数**仅展示不可点。`/servers/stats` 新增 `unset_path` 计数。
## 动机
用户需从「离线 3」快速查看离线机器,原先统计数字不可交互。
## 涉及文件
- `frontend/src/components/StatCardsRow.vue`
- `frontend/src/composables/useServerPagination.ts`
- `frontend/src/pages/ServersPage.vue`
- `frontend/src/components/servers/ServerUnsetPathPanel.vue`
- `server/api/servers.py`
## 验证
`cd frontend && npm run type-check`;点击「离线」后列表仅显示 `is_online=false` 服务器。
+37 -4
View File
@@ -4,26 +4,48 @@ export interface StatCardItem {
title: string
icon: string
color: string
/** Optional id for click handler (e.g. total | online | offline | alerts) */
key?: string
clickable?: boolean
active?: boolean
}
defineProps<{
items: StatCardItem[]
}>()
const emit = defineEmits<{
click: [key: string]
}>()
function onCardClick(item: StatCardItem) {
if (item.clickable === false || !item.key) return
emit('click', item.key)
}
</script>
<template>
<v-row>
<v-col
v-for="(item, i) in items"
:key="i"
:key="item.key ?? i"
cols="6"
sm="3"
:sm="items.length > 4 ? 4 : 3"
:lg="items.length > 4 ? 2 : 3"
>
<v-card
elevation="0"
rounded="xl"
border
class="stat-card"
:class="{
'stat-card--clickable': item.clickable !== false && item.key,
'stat-card--active': item.active,
}"
:role="item.clickable !== false && item.key ? 'button' : undefined"
:tabindex="item.clickable !== false && item.key ? 0 : undefined"
@click="onCardClick(item)"
@keydown.enter.prevent="onCardClick(item)"
>
<v-card-text class="pa-4 d-flex align-center justify-space-between">
<div>
@@ -52,9 +74,20 @@ defineProps<{
<style scoped>
.stat-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.stat-card:hover {
.stat-card--clickable {
cursor: pointer;
}
.stat-card--clickable:focus-visible {
outline: 2px solid rgb(var(--v-theme-primary));
outline-offset: 2px;
}
.stat-card--active {
border-color: rgb(var(--v-theme-primary)) !important;
box-shadow: 0 0 0 1px rgba(var(--v-theme-primary), 0.35) !important;
}
.stat-card--clickable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 24px rgba(var(--v-theme-primary), 0.08) !important;
}
@@ -1,5 +1,12 @@
<template>
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card
id="unset-path-panel"
elevation="0"
rounded="lg"
class="my-5 unset-path-panel"
:class="{ 'unset-path-panel--highlight': highlight }"
border
>
<v-card-title class="d-flex align-center flex-wrap ga-2">
未设置路径服务器
<v-chip v-if="total > 0" size="small" color="warning" variant="tonal" label>
@@ -189,6 +196,7 @@ import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
const props = defineProps<{
highlight?: boolean
servers: ServerApiItem[]
loading: boolean
total: number
@@ -274,4 +282,10 @@ function isExpandedId(id: number): boolean {
background: transparent !important;
border-bottom: none;
}
.unset-path-panel--highlight {
border-color: rgb(var(--v-theme-warning)) !important;
box-shadow: 0 0 0 2px rgba(var(--v-theme-warning), 0.35) !important;
scroll-margin-top: 80px;
}
</style>
@@ -25,12 +25,15 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
const search = ref('')
/** API category filter; empty string = all */
const categoryFilter = ref('')
/** null = all; true = online only; false = offline only */
const isOnlineFilter = ref<boolean | null>(null)
const sortBy = ref<TableSortItem[]>([])
function listQueryParams(): Record<string, unknown> {
return {
search: search.value || undefined,
category: categoryFilter.value || undefined,
...(isOnlineFilter.value !== null ? { is_online: isOnlineFilter.value } : {}),
...toServerApiSort(sortBy.value),
...(targetPathUnset !== undefined ? { target_path_unset: targetPathUnset } : {}),
}
@@ -83,6 +86,19 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
loadServers()
}
function onOnlineFilterChange(filter: boolean | null) {
isOnlineFilter.value = filter
page.value = 1
loadServers()
}
function clearOnlineFilter() {
if (isOnlineFilter.value === null) return
isOnlineFilter.value = null
page.value = 1
loadServers()
}
watch(
sortBy,
() => {
@@ -103,8 +119,9 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
}
return {
servers, loading, page, itemsPerPage, total, search, categoryFilter, sortBy,
servers, loading, page, itemsPerPage, total, search, categoryFilter, isOnlineFilter, sortBy,
loadServers, onSearch, onPageChange, onItemsPerPageChange, onCategoryChange,
onOnlineFilterChange, clearOnlineFilter,
loadAllServers, listQueryParams,
}
}
+97 -6
View File
@@ -1,7 +1,32 @@
<template>
<v-container fluid class="pa-4 pa-md-6">
<v-skeleton-loader v-if="statsBooting" type="heading, text" class="mb-4" />
<StatCardsRow v-else :items="statItems" />
<StatCardsRow v-else :items="statItemsDisplay" @click="onStatCardClick" />
<div v-if="statusFilterLabel || unsetPathFocus" class="d-flex align-center flex-wrap ga-2 mb-3">
<v-chip
v-if="statusFilterLabel"
color="primary"
variant="tonal"
size="small"
closable
label
@click:close="clearStatusFilter"
>
{{ statusFilterLabel }}
</v-chip>
<v-chip
v-if="unsetPathFocus"
color="warning"
variant="tonal"
size="small"
closable
label
@click:close="clearUnsetPathFocus"
>
已定位未设置路径服务器
</v-chip>
<span class="text-caption text-medium-emphasis">点击顶部统计卡可切换筛选或定位</span>
</div>
<!-- Server Table -->
<v-card elevation="0" rounded="lg" class="my-5" border>
@@ -292,6 +317,7 @@
</v-card>
<ServerUnsetPathPanel
:highlight="unsetPathFocus"
:servers="unsetPathServers"
:loading="unsetPathLoading"
:total="unsetPathTotal"
@@ -595,7 +621,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, type Ref } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, type Ref } from 'vue'
import { usePageAutoRefresh } from '@/composables/usePageAutoRefresh'
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { CACHE_KEYS, cachedFetch, invalidateCachedQuery } from '@/composables/useCachedQuery'
@@ -693,8 +719,9 @@ watch(() => route.query.credentials, () => openCredentialsFromQuery(), { immedia
// ── Paginated server list (composable) ──
const {
servers, loading, page, itemsPerPage, total, search, categoryFilter, sortBy,
servers, loading, page, itemsPerPage, total, search, categoryFilter, isOnlineFilter, sortBy,
loadServers: _load, onSearch, onPageChange, onItemsPerPageChange, onCategoryChange,
onOnlineFilterChange, clearOnlineFilter,
loadAllServers, listQueryParams,
} = useServerPagination({ targetPathUnset: false })
@@ -1144,25 +1171,89 @@ const headers = [
const statsBooting = ref(true)
// ── Stats(与仪表盘 StatCardsRow 一致)──
// ── Stats(与仪表盘 StatCardsRow 一致;服务器页可点击筛选)──
const STAT_CARD_KEYS = ['total', 'online', 'offline', 'unset-path', 'alerts'] as const
const statItems = ref([
{ subtitle: '服务器总数', title: '0', icon: 'mdi-server-network', color: 'blue' },
{ subtitle: '在线', title: '0', icon: 'mdi-check-circle-outline', color: 'green' },
{ subtitle: '离线', title: '0', icon: 'mdi-alert-circle-outline', color: 'orange' },
{ subtitle: '未设路径', title: '0', icon: 'mdi-folder-off-outline', color: 'amber' },
{ subtitle: '告警中', title: '0', icon: 'mdi-bell-alert-outline', color: 'red' },
])
const unsetPathFocus = ref(false)
const statItemsDisplay = computed(() =>
statItems.value.map((item, i) => {
const key = STAT_CARD_KEYS[i]
let active = false
if (key === 'online') active = isOnlineFilter.value === true
else if (key === 'offline') active = isOnlineFilter.value === false
else if (key === 'unset-path') active = unsetPathFocus.value
return {
...item,
key,
clickable: key !== 'total',
active,
}
}),
)
const statusFilterLabel = computed(() => {
if (isOnlineFilter.value === true) return '筛选:在线服务器'
if (isOnlineFilter.value === false) return '筛选:离线服务器'
return ''
})
function scrollToUnsetPathPanel() {
void nextTick(() => {
document.getElementById('unset-path-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
function onStatCardClick(key: string) {
if (key === 'alerts') {
unsetPathFocus.value = false
router.push({ path: '/alerts' })
return
}
if (key === 'unset-path') {
unsetPathFocus.value = !unsetPathFocus.value
if (unsetPathFocus.value) {
scrollToUnsetPathPanel()
}
return
}
unsetPathFocus.value = false
if (key === 'online') {
onOnlineFilterChange(isOnlineFilter.value === true ? null : true)
} else if (key === 'offline') {
onOnlineFilterChange(isOnlineFilter.value === false ? null : false)
}
viewMode.value = 'table'
}
function clearStatusFilter() {
clearOnlineFilter()
}
function clearUnsetPathFocus() {
unsetPathFocus.value = false
}
async function loadStats(silent = false) {
if (!silent) statsBooting.value = true
try {
const { data: s } = await cachedFetch(
CACHE_KEYS.serverStats,
() => http.get<{ total: number; online: number; offline: number; alerts: number }>('/servers/stats'),
() => http.get<{ total: number; online: number; offline: number; unset_path: number; alerts: number }>('/servers/stats'),
)
statItems.value[0].title = String(s.total)
statItems.value[1].title = String(s.online)
statItems.value[2].title = String(s.offline)
statItems.value[3].title = String(s.alerts ?? 0)
statItems.value[3].title = String(s.unset_path ?? 0)
statItems.value[4].title = String(s.alerts ?? 0)
} catch { /* non-critical */ } finally {
if (!silent) statsBooting.value = false
}
+8
View File
@@ -203,6 +203,13 @@ async def server_stats(
unknown = max(0, total - monitored)
offline = max(0, monitored - online)
from server.infrastructure.database.server_repo import _target_path_unset_filter
unset_path_result = await db.execute(
select(func.count(Server.id)).where(_target_path_unset_filter(True))
)
unset_path = int(unset_path_result.scalar() or 0)
alerts = 0
try:
cursor = 0
@@ -219,6 +226,7 @@ async def server_stats(
"online": online,
"offline": offline,
"unknown": unknown,
"unset_path": unset_path,
"alerts": alerts,
"categories": categories,
}
@@ -25,6 +25,8 @@ async def test_server_stats_returns_counts(db_session, test_admin, test_server,
assert "total" in body
assert body["total"] >= 1
assert "online" in body
assert "unset_path" in body
assert isinstance(body["unset_path"], int)
assert "categories" in body