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:
@@ -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` 服务器。
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user