Files
Nexus/frontend/src/components/watch/WatchSlotCard.vue
T
Nexus Agent 32a1885f0d feat(watch): 监测槽开关、8h/24h、到期暂停与探针修复
支持随时暂停/恢复实时监测并保留槽位;TTL 到期自动暂停不占删槽;修复到期竞态导致空槽与唯一约束冲突;探针 parse_error 与中文错误;移除失败 snackbar。
2026-06-12 03:47:56 +08:00

198 lines
6.3 KiB
Vue

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WatchSlot } from '@/composables/useWatchPins'
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError } from '@/utils/watchFormat'
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
const props = defineProps<{
slot: WatchSlot
}>()
const emit = defineEmits<{
remove: [slotIndex: number]
processes: [slot: WatchSlot]
history: [slot: WatchSlot]
pick: [slotIndex: number]
monitoring: [slotIndex: number, enabled: boolean]
ttl: [slotIndex: number, hours: 8 | 24]
}>()
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24)
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
function pct(v: number | null | undefined) {
return v != null ? `${v}%` : '—'
}
</script>
<template>
<v-card
v-if="!slot.empty"
elevation="0"
border
rounded="lg"
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="d-flex align-center mb-2 ga-1" @click.stop>
<span class="text-caption text-medium-emphasis mr-1">时长</span>
<v-btn-toggle
:model-value="slotTtl"
density="compact"
variant="outlined"
divided
mandatory
@update:model-value="emit('ttl', slot.slot_index, ($event ?? 8) as 8 | 24)"
>
<v-btn :value="8" size="x-small">8h</v-btn>
<v-btn :value="24" size="x-small">24h</v-btn>
</v-btn-toggle>
</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>
<div class="d-flex justify-space-between text-caption mb-1">
<span>CPU {{ pct(slot.metrics?.cpu_pct) }}</span>
<span>MEM {{ pct(slot.metrics?.mem_pct) }}</span>
<span>DISK {{ pct(slot.metrics?.disk_pct) }}</span>
</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">MEM</v-btn>
<v-btn value="disk_pct" size="x-small">DISK</v-btn>
</v-btn-toggle>
<WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" />
<div
v-if="slot.metrics?.error"
class="text-caption text-error mt-1 text-truncate"
:title="formatProbeError(slot.metrics.error, slot.metrics.probe_status)"
>
{{ formatProbeError(slot.metrics.error, slot.metrics.probe_status) }}
</div>
<div class="d-flex align-center justify-space-between mt-2">
<span class="text-caption text-medium-emphasis">
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ slotTtl }}h
</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">已暂停 · {{ slotTtl }}h</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"
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: 220px;
}
.watch-slot-card--empty {
min-height: 220px;
border-style: dashed !important;
cursor: pointer;
transition: background-color 0.15s ease;
}
.watch-slot-card--empty:hover {
background: rgba(var(--v-theme-primary), 0.04);
}
.watch-slot-card--filled {
cursor: pointer;
}
.watch-slot-card--filled:hover {
background: rgba(var(--v-theme-surface-variant), 0.3);
}
.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);
}
</style>