Files
Nexus/frontend/src/components/watch/WatchSlotCard.vue
T
Nexus Agent 75efd506f5 feat(watch): 监测槽 SSH 一键安装 psutil
探针报 psutil missing 时显示安装按钮;POST install-psutil 经 SSH pip/apt 安装并审计。
2026-06-12 04:04:48 +08:00

216 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WatchSlot } from '@/composables/useWatchPins'
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError } from '@/utils/watchFormat'
import WatchSparkline from '@/components/watch/WatchSparkline.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, hours: 8 | 24]
installPsutil: [slotIndex: number]
}>()
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24)
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')
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"
: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)"
>
安装 psutilSSH
</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) }} · {{ 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>