ac98dc496c
v-card 关闭 link/ripple 并隐藏 overlay,仅保留外框主题色高亮。 Co-authored-by: Cursor <cursoragent@cursor.com>
268 lines
8.5 KiB
Vue
268 lines
8.5 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed } from 'vue'
|
||
import type { WatchSlot } from '@/composables/useWatchPins'
|
||
import {
|
||
WATCH_TTL_OPTIONS,
|
||
normalizeWatchTtlMinutes,
|
||
watchTtlLabel,
|
||
watchTtlShortLabel,
|
||
type WatchTtlMinutes,
|
||
} from '@/constants/watchTtl'
|
||
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError, loadAvgColor, formatLoadAvg } from '@/utils/watchFormat'
|
||
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
|
||
import WatchMetricBar from '@/components/watch/WatchMetricBar.vue'
|
||
|
||
const props = defineProps<{
|
||
slot: WatchSlot
|
||
psutilInstallLoading?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
remove: [slotIndex: number]
|
||
processes: [slot: WatchSlot]
|
||
history: [slot: WatchSlot]
|
||
pick: [slotIndex: number]
|
||
monitoring: [slotIndex: number, enabled: boolean]
|
||
ttl: [slotIndex: number, minutes: WatchTtlMinutes]
|
||
installPsutil: [slotIndex: number]
|
||
}>()
|
||
|
||
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
|
||
const slotTtlMinutes = computed(() =>
|
||
normalizeWatchTtlMinutes(props.slot.ttl_minutes ?? (props.slot.ttl_hours === 24 ? 1440 : props.slot.ttl_hours === 8 ? 480 : undefined)),
|
||
)
|
||
const showPsutilInstall = computed(() =>
|
||
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
|
||
)
|
||
|
||
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
|
||
|
||
const load1 = computed(() => props.slot.metrics?.load_1)
|
||
</script>
|
||
|
||
<template>
|
||
<v-card
|
||
v-if="!slot.empty"
|
||
elevation="0"
|
||
border
|
||
rounded="lg"
|
||
:link="false"
|
||
:ripple="false"
|
||
class="watch-slot-card pa-3 watch-slot-card--filled"
|
||
:class="{
|
||
'watch-slot-card--error': monitoringOn && slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok',
|
||
'watch-slot-card--paused': !monitoringOn,
|
||
}"
|
||
role="button"
|
||
tabindex="0"
|
||
@click="emit('history', slot)"
|
||
@keydown.enter.prevent="emit('history', slot)"
|
||
>
|
||
<div class="d-flex align-center mb-2" @click.stop>
|
||
<div class="text-subtitle-2 text-truncate flex-grow-1" :title="slot.server_name">
|
||
{{ slot.server_name || `ID ${slot.server_id}` }}
|
||
</div>
|
||
<v-chip size="x-small" variant="tonal" color="info" label>
|
||
槽 {{ slot.slot_index + 1 }}
|
||
</v-chip>
|
||
<v-switch
|
||
:model-value="monitoringOn"
|
||
density="compact"
|
||
hide-details
|
||
color="primary"
|
||
class="watch-slot-switch ml-1"
|
||
@click.stop
|
||
@update:model-value="emit('monitoring', slot.slot_index, $event ?? false)"
|
||
/>
|
||
<v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" />
|
||
</div>
|
||
|
||
<div class="watch-ttl-row mb-2" @click.stop>
|
||
<span class="text-caption text-medium-emphasis watch-ttl-label">时长</span>
|
||
<div class="watch-ttl-chips d-flex flex-wrap ga-1">
|
||
<v-chip
|
||
v-for="opt in WATCH_TTL_OPTIONS"
|
||
:key="opt.minutes"
|
||
:color="slotTtlMinutes === opt.minutes ? 'primary' : undefined"
|
||
:variant="slotTtlMinutes === opt.minutes ? 'flat' : 'outlined'"
|
||
size="x-small"
|
||
label
|
||
class="watch-ttl-chip"
|
||
@click="emit('ttl', slot.slot_index, opt.minutes)"
|
||
>
|
||
{{ opt.label }}
|
||
</v-chip>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!monitoringOn" class="text-caption text-medium-emphasis mb-2" @click.stop>
|
||
实时监测已关闭 · 槽位保留 · Agent 恢复 60s 智能上报
|
||
</div>
|
||
|
||
<template v-if="monitoringOn">
|
||
<div @click.stop>
|
||
<v-chip
|
||
v-if="slot.metrics?.probe_status"
|
||
size="x-small"
|
||
:color="probeStatusColor(slot.metrics.probe_status)"
|
||
variant="tonal"
|
||
class="mb-2"
|
||
label
|
||
>
|
||
{{ probeStatusLabel(slot.metrics.probe_status) }}
|
||
</v-chip>
|
||
|
||
<WatchMetricBar label="CPU" :value="slot.metrics?.cpu_pct" />
|
||
<WatchMetricBar label="内存" :value="slot.metrics?.mem_pct" />
|
||
<WatchMetricBar label="硬盘" :value="slot.metrics?.disk_pct" />
|
||
|
||
<div class="d-flex align-center justify-space-between text-caption mb-2 mt-1">
|
||
<span class="text-medium-emphasis">负载</span>
|
||
<v-tooltip location="top" text="1 分钟平均负载,与宝塔面板「负载」相同(非 CPU 百分比)">
|
||
<template #activator="{ props: tipProps }">
|
||
<v-chip
|
||
v-bind="tipProps"
|
||
size="x-small"
|
||
:color="loadAvgColor(load1)"
|
||
variant="tonal"
|
||
label
|
||
>
|
||
{{ formatLoadAvg(load1) }}
|
||
</v-chip>
|
||
</template>
|
||
</v-tooltip>
|
||
</div>
|
||
|
||
<div class="text-caption text-medium-emphasis mb-1">
|
||
↑ {{ formatBytesPerSec(slot.metrics?.net_up_bps) }}
|
||
· ↓ {{ formatBytesPerSec(slot.metrics?.net_down_bps) }}
|
||
</div>
|
||
<div class="text-caption text-medium-emphasis mb-2">
|
||
读 {{ formatBytesPerSec(slot.metrics?.disk_read_bps) }}
|
||
· 写 {{ formatBytesPerSec(slot.metrics?.disk_write_bps) }}
|
||
</div>
|
||
|
||
<v-btn-toggle v-model="chartMetric" density="compact" variant="outlined" divided class="mb-1">
|
||
<v-btn value="cpu_pct" size="x-small">CPU</v-btn>
|
||
<v-btn value="mem_pct" size="x-small">内存</v-btn>
|
||
<v-btn value="disk_pct" size="x-small">硬盘</v-btn>
|
||
</v-btn-toggle>
|
||
|
||
<WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" />
|
||
|
||
<div
|
||
v-if="slot.metrics?.error"
|
||
class="text-caption text-error mt-1"
|
||
:title="formatProbeError(slot.metrics.error, slot.metrics.probe_status)"
|
||
>
|
||
<div class="text-truncate">
|
||
{{ formatProbeError(slot.metrics.error, slot.metrics.probe_status) }}
|
||
</div>
|
||
<v-btn
|
||
v-if="showPsutilInstall"
|
||
size="x-small"
|
||
variant="tonal"
|
||
color="primary"
|
||
class="mt-1"
|
||
:loading="props.psutilInstallLoading"
|
||
@click.stop="emit('installPsutil', slot.slot_index)"
|
||
>
|
||
安装 psutil(SSH)
|
||
</v-btn>
|
||
</div>
|
||
|
||
<div class="d-flex align-center justify-space-between mt-2">
|
||
<span class="text-caption text-medium-emphasis">
|
||
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
|
||
</span>
|
||
<div class="d-flex ga-1">
|
||
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn>
|
||
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-else class="d-flex align-center justify-space-between mt-2" @click.stop>
|
||
<span class="text-caption text-medium-emphasis">已暂停 · {{ watchTtlLabel(slotTtlMinutes) }}</span>
|
||
<div class="d-flex ga-1">
|
||
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
||
</div>
|
||
</div>
|
||
</v-card>
|
||
|
||
<v-card
|
||
v-else
|
||
elevation="0"
|
||
border
|
||
rounded="lg"
|
||
:link="false"
|
||
:ripple="false"
|
||
class="watch-slot-card watch-slot-card--empty d-flex align-center justify-center pa-4"
|
||
variant="outlined"
|
||
role="button"
|
||
tabindex="0"
|
||
@click="emit('pick', slot.slot_index)"
|
||
@keydown.enter.prevent="emit('pick', slot.slot_index)"
|
||
>
|
||
<div class="text-center text-medium-emphasis">
|
||
<v-icon size="28" class="mb-1">mdi-plus-circle-outline</v-icon>
|
||
<div class="text-caption">槽 {{ slot.slot_index + 1 }} · 添加监测</div>
|
||
</div>
|
||
</v-card>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.watch-slot-card {
|
||
min-height: 280px;
|
||
}
|
||
/* Vuetify v-card--link 会在 hover 时铺 v-card__overlay 灰层,此处仅保留外框高亮 */
|
||
.watch-slot-card :deep(.v-card__overlay) {
|
||
opacity: 0 !important;
|
||
}
|
||
.watch-slot-card--empty {
|
||
min-height: 220px;
|
||
border-style: dashed !important;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s ease;
|
||
}
|
||
.watch-slot-card--empty:hover {
|
||
border-color: rgb(var(--v-theme-primary)) !important;
|
||
}
|
||
.watch-slot-card--filled {
|
||
cursor: pointer;
|
||
transition: border-color 0.15s ease;
|
||
}
|
||
.watch-slot-card--filled:hover {
|
||
border-color: rgb(var(--v-theme-primary)) !important;
|
||
}
|
||
.watch-slot-card--error {
|
||
border-color: rgb(var(--v-theme-error)) !important;
|
||
}
|
||
.watch-slot-card--paused {
|
||
opacity: 0.72;
|
||
background: rgba(var(--v-theme-surface-variant), 0.15);
|
||
}
|
||
.watch-slot-switch {
|
||
flex: 0 0 auto;
|
||
transform: scale(0.85);
|
||
}
|
||
.watch-ttl-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 6px;
|
||
}
|
||
.watch-ttl-label {
|
||
flex: 0 0 auto;
|
||
line-height: 24px;
|
||
}
|
||
.watch-ttl-chips {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
.watch-ttl-chip {
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
</style>
|