468 lines
13 KiB
Vue
468 lines
13 KiB
Vue
<script setup>
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
|
||
import { fetchDockerStats, fetchRunningTasks, fetchServerInfo, fetchTaskStats } from '../api/tasks'
|
||
|
||
const loading = ref(false)
|
||
|
||
const server = ref({
|
||
cpu_percent: '-',
|
||
memory_used: '-',
|
||
memory_total: '-',
|
||
disk_used: '-',
|
||
disk_total: '-',
|
||
uptime: '-',
|
||
})
|
||
|
||
const docker = ref({
|
||
status: 'Unknown',
|
||
memory_usage: 'N/A',
|
||
memory_limit: 'N/A',
|
||
memory_percent: 'N/A',
|
||
uptime: 'N/A',
|
||
})
|
||
|
||
const taskStats = ref({
|
||
today: { success_tasks: 0, failed_tasks: 0, total_items: 0, total_attachments: 0 },
|
||
total: { success_tasks: 0, failed_tasks: 0, total_items: 0, total_attachments: 0 },
|
||
})
|
||
|
||
const monitor = ref({
|
||
running_count: 0,
|
||
queuing_count: 0,
|
||
max_concurrent: 0,
|
||
running: [],
|
||
queuing: [],
|
||
})
|
||
|
||
const sourceMap = {
|
||
manual: { label: '手动', type: 'success' },
|
||
scheduled: { label: '定时', type: 'primary' },
|
||
immediate: { label: '即时', type: 'warning' },
|
||
resumed: { label: '恢复', type: 'info' },
|
||
}
|
||
|
||
const statusColorMap = {
|
||
初始化: '#6b7280',
|
||
正在登录: '#f59e0b',
|
||
正在浏览: '#10b981',
|
||
浏览完成: '#3b82f6',
|
||
正在截图: '#06b6d4',
|
||
}
|
||
|
||
function statusColor(text) {
|
||
return statusColorMap[text] || '#6b7280'
|
||
}
|
||
|
||
const serverMemoryDisplay = computed(() => `${server.value.memory_used} / ${server.value.memory_total}`)
|
||
const serverDiskDisplay = computed(() => `${server.value.disk_used} / ${server.value.disk_total}`)
|
||
|
||
let stop = false
|
||
let timer = null
|
||
|
||
async function loadOnce() {
|
||
loading.value = true
|
||
try {
|
||
const [serverInfo, dockerInfo, taskStat, running] = await Promise.all([
|
||
fetchServerInfo(),
|
||
fetchDockerStats(),
|
||
fetchTaskStats(),
|
||
fetchRunningTasks(),
|
||
])
|
||
|
||
server.value = serverInfo || server.value
|
||
docker.value = dockerInfo || docker.value
|
||
taskStats.value = taskStat || taskStats.value
|
||
monitor.value = running || monitor.value
|
||
} catch {
|
||
// handled by interceptor
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loop() {
|
||
if (stop) return
|
||
const start = Date.now()
|
||
await loadOnce()
|
||
if (stop) return
|
||
const elapsed = Date.now() - start
|
||
// server/info 正常会阻塞约 1s;如果异常很快失败,避免疯狂重试
|
||
timer = window.setTimeout(loop, elapsed < 900 ? 1000 : 0)
|
||
}
|
||
|
||
onMounted(() => {
|
||
stop = false
|
||
loop()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
stop = true
|
||
if (timer) window.clearTimeout(timer)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-stack" v-loading="loading">
|
||
<div class="app-page-title">
|
||
<h2>统计</h2>
|
||
<span class="app-muted">实时更新</span>
|
||
</div>
|
||
|
||
<el-row :gutter="12">
|
||
<el-col :xs="12" :sm="8" :md="6">
|
||
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
|
||
<div class="metric-label">CPU</div>
|
||
<div class="metric-value">{{ server.cpu_percent }}%</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :xs="12" :sm="8" :md="6">
|
||
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
|
||
<div class="metric-label">内存</div>
|
||
<div class="metric-value">{{ serverMemoryDisplay }}</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :xs="12" :sm="8" :md="6">
|
||
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
|
||
<div class="metric-label">磁盘</div>
|
||
<div class="metric-value">{{ serverDiskDisplay }}</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :xs="12" :sm="8" :md="6">
|
||
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
|
||
<div class="metric-label">容器内存</div>
|
||
<div class="metric-value">{{ docker.memory_limit !== 'N/A' ? `${docker.memory_usage} / ${docker.memory_limit}` : docker.memory_usage }}</div>
|
||
<div v-if="docker.memory_percent !== 'N/A'" class="metric-sub app-muted">{{ docker.memory_percent }}</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="12">
|
||
<el-col :xs="24" :md="14">
|
||
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
|
||
<div class="section-head">
|
||
<h3 class="section-title">实时监控</h3>
|
||
<span class="app-muted">最大并发:{{ monitor.max_concurrent }}</span>
|
||
</div>
|
||
|
||
<el-row :gutter="12" class="count-row">
|
||
<el-col :span="8">
|
||
<el-card shadow="never" class="count-card ok" :body-style="{ padding: '12px' }">
|
||
<div class="count-value">{{ monitor.running_count }}</div>
|
||
<div class="count-label">运行中</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<el-card shadow="never" class="count-card warn" :body-style="{ padding: '12px' }">
|
||
<div class="count-value">{{ monitor.queuing_count }}</div>
|
||
<div class="count-label">排队中</div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<el-card shadow="never" class="count-card" :body-style="{ padding: '12px' }">
|
||
<div class="count-value">{{ monitor.max_concurrent }}</div>
|
||
<div class="count-label">并发上限</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<div class="sub-title">运行中任务</div>
|
||
<div v-if="monitor.running.length === 0" class="empty app-muted">暂无运行中的任务</div>
|
||
<div v-else class="task-list">
|
||
<div v-for="t in monitor.running" :key="`r-${t.account_id}`" class="task-item">
|
||
<div class="task-left">
|
||
<div class="task-line">
|
||
<el-tag :type="(sourceMap[t.source] || sourceMap.manual).type" effect="light" size="small">
|
||
{{ (sourceMap[t.source] || sourceMap.manual).label }}
|
||
</el-tag>
|
||
<span class="task-user">{{ t.user_username }}</span>
|
||
<span class="app-muted">→</span>
|
||
<span class="task-account">{{ t.username }}</span>
|
||
<el-tag effect="plain" size="small">{{ t.browse_type }}</el-tag>
|
||
</div>
|
||
<div class="task-line2">
|
||
<span class="dot" :style="{ background: statusColor(t.detail_status) }"></span>
|
||
<span class="task-status" :style="{ color: statusColor(t.detail_status) }">{{ t.detail_status }}</span>
|
||
<span v-if="t.progress_items || t.progress_attachments" class="app-muted"
|
||
>内容/附件:{{ t.progress_items }} / {{ t.progress_attachments }}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<div class="task-right">{{ t.elapsed_display }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sub-title">排队中任务</div>
|
||
<div v-if="monitor.queuing.length === 0" class="empty app-muted">暂无排队中的任务</div>
|
||
<div v-else class="task-list">
|
||
<div v-for="t in monitor.queuing" :key="`q-${t.account_id}`" class="task-item queue">
|
||
<div class="task-left">
|
||
<div class="task-line">
|
||
<el-tag :type="(sourceMap[t.source] || sourceMap.manual).type" effect="light" size="small">
|
||
{{ (sourceMap[t.source] || sourceMap.manual).label }}
|
||
</el-tag>
|
||
<span class="task-user">{{ t.user_username }}</span>
|
||
<span class="app-muted">→</span>
|
||
<span class="task-account">{{ t.username }}</span>
|
||
<el-tag effect="plain" size="small">{{ t.browse_type }}</el-tag>
|
||
</div>
|
||
<div class="task-line2">
|
||
<span class="dot" style="background: #f59e0b"></span>
|
||
<span class="task-status" style="color: #f59e0b">{{ t.detail_status || '等待资源' }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="task-right warn">{{ t.elapsed_display }}</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
|
||
<el-col :xs="24" :md="10">
|
||
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
|
||
<div class="section-head">
|
||
<h3 class="section-title">任务统计</h3>
|
||
<span class="app-muted">运行:{{ server.uptime }}</span>
|
||
</div>
|
||
|
||
<div class="stat-grid">
|
||
<div class="stat-box ok">
|
||
<div class="stat-name">成功任务</div>
|
||
<div class="stat-row">
|
||
<span class="stat-big">{{ taskStats.today.success_tasks }}</span>
|
||
<span class="app-muted">今日</span>
|
||
</div>
|
||
<div class="stat-row2 app-muted">累计:{{ taskStats.total.success_tasks }}</div>
|
||
</div>
|
||
<div class="stat-box err">
|
||
<div class="stat-name">失败任务</div>
|
||
<div class="stat-row">
|
||
<span class="stat-big">{{ taskStats.today.failed_tasks }}</span>
|
||
<span class="app-muted">今日</span>
|
||
</div>
|
||
<div class="stat-row2 app-muted">累计:{{ taskStats.total.failed_tasks }}</div>
|
||
</div>
|
||
<div class="stat-box info">
|
||
<div class="stat-name">浏览内容</div>
|
||
<div class="stat-row">
|
||
<span class="stat-big">{{ taskStats.today.total_items }}</span>
|
||
<span class="app-muted">今日</span>
|
||
</div>
|
||
<div class="stat-row2 app-muted">累计:{{ taskStats.total.total_items }}</div>
|
||
</div>
|
||
<div class="stat-box info2">
|
||
<div class="stat-name">查看附件</div>
|
||
<div class="stat-row">
|
||
<span class="stat-big">{{ taskStats.today.total_attachments }}</span>
|
||
<span class="app-muted">今日</span>
|
||
</div>
|
||
<div class="stat-row2 app-muted">累计:{{ taskStats.total.total_attachments }}</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.metric-card,
|
||
.card {
|
||
border-radius: var(--app-radius);
|
||
border: 1px solid var(--app-border);
|
||
}
|
||
|
||
.metric-label {
|
||
font-size: 12px;
|
||
color: var(--app-muted);
|
||
}
|
||
|
||
.metric-value {
|
||
margin-top: 6px;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.metric-sub {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.section-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.section-title {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.count-row {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.count-card {
|
||
border-radius: 10px;
|
||
border: 1px solid var(--app-border);
|
||
}
|
||
|
||
.count-card.ok {
|
||
background: rgba(16, 185, 129, 0.08);
|
||
}
|
||
|
||
.count-card.warn {
|
||
background: rgba(245, 158, 11, 0.08);
|
||
}
|
||
|
||
.count-value {
|
||
font-size: 22px;
|
||
font-weight: 900;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.count-label {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: var(--app-muted);
|
||
}
|
||
|
||
.sub-title {
|
||
margin-top: 14px;
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.empty {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.task-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.task-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--app-border);
|
||
background: #fff;
|
||
}
|
||
|
||
.task-item.queue {
|
||
background: rgba(245, 158, 11, 0.06);
|
||
}
|
||
|
||
.task-line {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.task-line2 {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.task-user {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.task-account {
|
||
font-weight: 700;
|
||
color: #2563eb;
|
||
}
|
||
|
||
.dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 999px;
|
||
display: inline-block;
|
||
}
|
||
|
||
.task-status {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.task-right {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #10b981;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.task-right.warn {
|
||
color: #f59e0b;
|
||
}
|
||
|
||
.stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.stat-box {
|
||
border-radius: 12px;
|
||
border: 1px solid var(--app-border);
|
||
padding: 12px;
|
||
}
|
||
|
||
.stat-box.ok {
|
||
background: rgba(16, 185, 129, 0.08);
|
||
}
|
||
|
||
.stat-box.err {
|
||
background: rgba(239, 68, 68, 0.08);
|
||
}
|
||
|
||
.stat-box.info {
|
||
background: rgba(59, 130, 246, 0.08);
|
||
}
|
||
|
||
.stat-box.info2 {
|
||
background: rgba(6, 182, 212, 0.08);
|
||
}
|
||
|
||
.stat-name {
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.stat-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
}
|
||
|
||
.stat-big {
|
||
font-size: 20px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.stat-row2 {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
}
|
||
</style>
|