优化监控任务多账号配置

This commit is contained in:
yml
2026-05-25 17:44:42 +08:00
parent 30728c40be
commit d58d483944
17 changed files with 1062 additions and 182 deletions
@@ -73,6 +73,7 @@ const {
scheduledJobsForm,
scheduledJobs,
scheduledJobRuntime,
cloudtentaclesMonitorAccounts,
scheduledJobsSaving,
scheduledJobRunningId,
notificationStats,
@@ -368,6 +369,7 @@ onMounted(loadConfigs)
:scheduled-jobs-form="scheduledJobsForm"
:scheduled-jobs="scheduledJobs"
:scheduled-job-runtime="scheduledJobRuntime"
:cloudtentacles-monitor-accounts="cloudtentaclesMonitorAccounts"
:scheduled-jobs-saving="scheduledJobsSaving"
:scheduled-job-running-id="scheduledJobRunningId"
:notification-stats="notificationStats"
@@ -1,7 +1,16 @@
<script setup lang="ts">
import type { AdminScheduledJobRuntimeState } from '@/types/admin'
import type { EditableScheduledJob } from '../../composables/types'
import { computed } from 'vue'
import type {
AdminCloudtentaclesMonitorAccount,
AdminScheduledJobAccountRuntime,
AdminScheduledJobRuntimeState,
} from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import type {
EditableScheduledJob,
EditableScheduledJobAccount,
} from '../../composables/types'
const timeUnitOptions = [
{ label: '秒', value: 1 },
@@ -9,6 +18,22 @@ const timeUnitOptions = [
{ label: '小时', value: 3600 },
]
type Props = {
scheduledJobsForm: {
enabled: boolean
}
scheduledJobs: EditableScheduledJob[]
scheduledJobRuntime: AdminScheduledJobRuntimeState[]
cloudtentaclesMonitorAccounts: AdminCloudtentaclesMonitorAccount[]
scheduledJobsSaving: boolean
scheduledJobRunningId: string
handleScheduledJobsSaveConfig: () => void | Promise<void>
handleScheduledJobRunNow: (jobId: string) => void | Promise<void>
getScheduledJobRuntime: (jobId: string) => AdminScheduledJobRuntimeState | null
}
const props = defineProps<Props>()
function formatJobType(type: string) {
if (type === 'cloudtentacles_health') return 'cloudtentacles 健康检查'
return type || '-'
@@ -69,22 +94,55 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
})
}
import { computed } from 'vue'
type Props = {
scheduledJobsForm: {
enabled: boolean
}
scheduledJobs: EditableScheduledJob[]
scheduledJobRuntime: AdminScheduledJobRuntimeState[]
scheduledJobsSaving: boolean
scheduledJobRunningId: string
handleScheduledJobsSaveConfig: () => void | Promise<void>
handleScheduledJobRunNow: (jobId: string) => void | Promise<void>
getScheduledJobRuntime: (jobId: string) => AdminScheduledJobRuntimeState | null
function getMonitorAccount(sourceKey: string) {
return props.cloudtentaclesMonitorAccounts.find((item) => item.sourceKey === sourceKey) || null
}
defineProps<Props>()
function getAccountRuntime(
runtime: AdminScheduledJobRuntimeState | null,
account: EditableScheduledJobAccount,
) {
return runtime?.lastAccounts?.find((item) => item.sourceKey === account.sourceKey) || null
}
function formatStatus(status: string) {
const normalized = String(status || '').trim()
if (normalized === 'ok') return '正常'
if (normalized === 'asset_low') return '余额预警'
if (normalized === 'auth_missing') return '未登录'
if (normalized === 'source_missing') return '账号缺失'
if (normalized === 'disabled') return '已停用'
if (normalized === 'skipped') return '已跳过'
if (normalized === 'running') return '执行中'
if (normalized === 'failed') return '检查失败'
if (normalized === 'pending') return '待执行'
return normalized || '待执行'
}
function resolveStatusType(status: string) {
const normalized = String(status || '').trim()
if (normalized === 'ok') return 'success'
if (normalized === 'asset_low' || normalized === 'running') return 'warning'
if (['auth_missing', 'source_missing', 'failed'].includes(normalized)) return 'danger'
return 'info'
}
function formatAccountMeta(account: EditableScheduledJobAccount) {
const source = getMonitorAccount(account.sourceKey)
if (!source) return '账号配置不存在'
return [
source.username ? `登录名 ${source.username}` : '',
source.phoneMasked ? `手机 ${source.phoneMasked}` : '',
source.hasToken ? 'Token 已保存' : '未登录',
].filter(Boolean).join(' · ')
}
function formatAccountRuntimeSummary(runtime: AdminScheduledJobAccountRuntime | null) {
if (!runtime) return '尚未检查'
if (runtime.skipped) return runtime.message || '已跳过'
return runtime.message || formatStatus(runtime.status)
}
</script>
<template>
@@ -94,7 +152,7 @@ defineProps<Props>()
<div>
<span class="card-title">监控任务</span>
<p class="card-desc">
间隔表示多久检查一次冷却表示同一个异常在这段时间内最多通知一次
监控任务会按账号分别检查 cloudtentacles 登录态和余额每个账号可设置独立阈值
</p>
</div>
<div class="card-actions">
@@ -108,116 +166,213 @@ defineProps<Props>()
</div>
</template>
<el-form label-width="auto" label-position="top">
<el-form label-position="top">
<el-form-item>
<el-switch v-model="scheduledJobsForm.enabled" active-text="启用监控任务系统" />
</el-form-item>
</el-form>
<el-table :data="scheduledJobs" stripe size="small" class="jobs-table">
<el-table-column label="任务" min-width="200">
<template #default="{ row: job }">
<div class="job-list">
<section v-for="job in scheduledJobs" :key="job.id" class="job-panel">
<div class="job-panel-head">
<div>
<div class="fw-600">{{ formatJobType(job.type) }}</div>
<el-switch v-model="job.enabled" size="small" active-text="启用" class="job-switch" />
<div class="job-title-row">
<span class="fw-600">{{ formatJobType(job.type) }}</span>
<el-tag size="small" :type="job.enabled ? 'success' : 'info'">
{{ job.enabled ? '已启用' : '已停用' }}
</el-tag>
</div>
<div class="job-subtitle">
{{ getScheduledJobRuntime(job.id)?.lastMessage || '等待首次执行' }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="参数" min-width="320">
<template #default="{ row: job }">
<div class="job-params">
<div class="param-row">
<span class="param-label">检查间隔</span>
<div class="job-actions">
<el-switch v-model="job.enabled" active-text="启用任务" />
<el-button
:loading="scheduledJobRunningId === job.id"
size="small"
@click="handleScheduledJobRunNow(job.id)"
>
立即检查
</el-button>
</div>
</div>
<div class="job-settings">
<label class="duration-field">
<span class="param-label">检查间隔</span>
<span class="duration-controls">
<el-input-number
v-model.number="bindDuration(job, 'intervalSeconds').value"
:min="1"
class="mini-input"
class="duration-input"
controls-position="right"
/>
<el-select
v-model.number="bindDurationUnit(job, 'intervalSeconds').value"
class="mini-select"
class="duration-select"
>
<el-option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">
<el-option
v-for="unit in timeUnitOptions"
:key="unit.value"
:label="unit.label"
:value="unit.value"
>
{{ unit.label }}
</el-option>
</el-select>
<span class="param-hint"> {{ formatDuration(job.intervalSeconds) }} 检查一次</span>
</div>
<div class="param-row">
<span class="param-label">通知冷却</span>
</span>
<span class="param-hint"> {{ formatDuration(job.intervalSeconds) }} 检查一次</span>
</label>
<label class="duration-field">
<span class="param-label">通知冷却</span>
<span class="duration-controls">
<el-input-number
v-model.number="bindDuration(job, 'cooldownSeconds').value"
:min="1"
class="mini-input"
class="duration-input"
controls-position="right"
/>
<el-select
v-model.number="bindDurationUnit(job, 'cooldownSeconds').value"
class="mini-select"
class="duration-select"
>
<el-option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">
<el-option
v-for="unit in timeUnitOptions"
:key="unit.value"
:label="unit.label"
:value="unit.value"
>
{{ unit.label }}
</el-option>
</el-select>
<span class="param-hint"
>同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span
>
</div>
<div class="param-row">
<span class="param-label">余额阈值</span>
<el-input-number
v-model.number="job.assetThreshold"
:min="0"
class="mini-input"
controls-position="right"
/>
<span class="param-hint">低于该值时通知</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="最近状态" min-width="160">
<template #default="{ row: job }">
<div class="runtime-info">
</span>
<span class="param-hint"
>同一账号同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span
>
</label>
</div>
<div class="runtime-summary">
<div class="summary-item">
<span class="summary-label">任务状态</span>
<el-tag
:type="getScheduledJobRuntime(job.id)?.lastStatus === 'success' ? 'success' : 'info'"
:type="resolveStatusType(getScheduledJobRuntime(job.id)?.lastStatus || 'pending')"
size="small"
>
{{ getScheduledJobRuntime(job.id)?.lastStatus || 'pending' }}
{{ formatStatus(getScheduledJobRuntime(job.id)?.lastStatus || 'pending') }}
</el-tag>
<span class="param-hint">{{ getScheduledJobRuntime(job.id)?.lastMessage || '-' }}</span>
<span class="param-hint"
>余额{{ getScheduledJobRuntime(job.id)?.lastAsset ?? '-' }}</span
>
<span class="param-hint"
>上次{{
formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '')
}}</span
>
<span class="param-hint"
>下次{{
formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '')
}}</span
</div>
<div class="summary-item">
<span class="summary-label">账号</span>
<span class="summary-value"
><strong>{{ getScheduledJobRuntime(job.id)?.lastCheckedCount ?? 0 }}</strong>
<span class="summary-muted">/ {{ job.accounts.length }} 已检查</span></span
>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center">
<template #default="{ row: job }">
<el-button
:loading="scheduledJobRunningId === job.id"
size="small"
@click="handleScheduledJobRunNow(job.id)"
>
立即检查
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无可配置的监控任务。" :image-size="60" />
</template>
</el-table>
<div class="summary-item">
<span class="summary-label">预警</span>
<span class="summary-value"
><strong>{{ getScheduledJobRuntime(job.id)?.lastLowAssetCount ?? 0 }}</strong></span
>
</div>
<div class="summary-item">
<span class="summary-label">异常</span>
<span class="summary-value"
><strong>{{ getScheduledJobRuntime(job.id)?.lastFailedCount ?? 0 }}</strong></span
>
</div>
<div class="summary-item">
<span class="summary-label">上次</span>
<span class="summary-value">{{
formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '')
}}</span>
</div>
<div class="summary-item">
<span class="summary-label">下次</span>
<span class="summary-value">{{
formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '')
}}</span>
</div>
</div>
<el-table :data="job.accounts" size="small" class="accounts-table">
<el-table-column label="账号" min-width="230">
<template #default="{ row: account }">
<div class="account-title">{{ account.label || account.sourceKey }}</div>
<div class="account-meta">{{ account.sourceKey }}</div>
<div class="account-meta">{{ formatAccountMeta(account) }}</div>
</template>
</el-table-column>
<el-table-column label="监控" width="110" align="center">
<template #default="{ row: account }">
<el-switch v-model="account.enabled" size="small" />
</template>
</el-table-column>
<el-table-column label="余额阈值" width="150">
<template #default="{ row: account }">
<el-input-number
v-model.number="account.assetThreshold"
:min="0"
class="threshold-input"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column label="最近结果" min-width="260">
<template #default="{ row: account }">
<div
v-if="getAccountRuntime(getScheduledJobRuntime(job.id), account)"
class="account-runtime"
>
<div class="runtime-line">
<el-tag
:type="
resolveStatusType(
getAccountRuntime(getScheduledJobRuntime(job.id), account)?.status ||
'pending',
)
"
size="small"
>
{{
formatStatus(
getAccountRuntime(getScheduledJobRuntime(job.id), account)?.status ||
'pending',
)
}}
</el-tag>
<span>
余额
{{ getAccountRuntime(getScheduledJobRuntime(job.id), account)?.asset ?? '-' }}
/ 阈值
{{
getAccountRuntime(getScheduledJobRuntime(job.id), account)?.threshold ??
account.assetThreshold
}}
</span>
</div>
<div class="account-meta">
{{ formatAccountRuntimeSummary(getAccountRuntime(getScheduledJobRuntime(job.id), account)) }}
</div>
<div class="account-meta">
{{
formatAdminDateTime(
getAccountRuntime(getScheduledJobRuntime(job.id), account)?.checkedAt || '',
)
}}
</div>
</div>
<span v-else class="account-meta">尚未检查</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无 cloudtentacles 账号,请先在履约平台配置账号。" :image-size="60" />
</template>
</el-table>
</section>
</div>
</el-card>
</template>
@@ -226,69 +381,168 @@ defineProps<Props>()
border-radius: var(--radius-lg);
}
.card-actions {
.card-actions,
.job-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.job-params {
display: grid;
gap: var(--space-2);
}
.jobs-table {
margin-top: var(--space-2);
}
.job-switch {
margin-top: var(--space-1);
}
.param-row {
display: flex;
align-items: center;
}
.job-list {
display: grid;
gap: var(--space-3);
}
.job-panel {
display: grid;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-surface);
}
.job-panel-head {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: flex-start;
}
.job-title-row {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
.param-label {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: 600;
min-width: 56px;
}
.mini-input {
width: 100px;
height: 32px;
padding: 0 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-default);
background: var(--bg-surface);
font-size: var(--text-sm);
text-align: center;
}
.mini-select {
width: 80px;
height: 32px;
padding: 0 4px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-default);
background: var(--bg-surface);
font-size: var(--text-sm);
}
.param-hint {
.job-subtitle,
.param-hint,
.summary-muted,
.account-meta {
font-size: var(--text-xs);
color: var(--text-muted);
}
.runtime-info {
.job-settings {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-3);
}
.duration-field {
display: grid;
grid-template-columns: 72px minmax(230px, 300px) minmax(180px, 1fr);
gap: var(--space-2);
align-items: center;
min-width: 0;
padding: var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-muted);
}
.param-label,
.summary-label {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: 600;
}
.param-label {
white-space: nowrap;
}
.duration-controls {
display: grid;
grid-template-columns: 144px 96px;
gap: var(--space-2);
align-items: center;
min-width: 0;
}
.duration-input {
width: 144px;
}
.duration-select {
width: 96px;
}
.runtime-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-sm);
background: var(--bg-muted);
}
.summary-item {
display: grid;
grid-template-rows: 18px minmax(24px, auto);
gap: 4px;
min-width: 0;
padding: var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.summary-value {
min-width: 0;
overflow-wrap: anywhere;
font-size: var(--text-sm);
color: var(--text-primary);
}
.accounts-table {
width: 100%;
}
.account-title {
font-weight: 600;
color: var(--text-primary);
}
.threshold-input {
width: 144px;
}
.account-runtime {
display: grid;
gap: 2px;
}
.runtime-line {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
font-size: var(--text-xs);
}
@media (max-width: 720px) {
.job-panel-head {
display: grid;
}
.job-actions {
justify-content: flex-start;
}
.duration-field {
grid-template-columns: 1fr;
}
.duration-controls {
grid-template-columns: minmax(0, 1fr) 96px;
}
.duration-input {
width: 100%;
}
}
</style>
@@ -1,5 +1,9 @@
<script setup lang="ts">
import type { AdminNotificationTestResult, AdminScheduledJobRuntimeState } from '@/types/admin'
import type {
AdminCloudtentaclesMonitorAccount,
AdminNotificationTestResult,
AdminScheduledJobRuntimeState,
} from '@/types/admin'
import type {
EditableNotificationRecipient,
EditableScheduledJob,
@@ -51,6 +55,7 @@ type Props = {
scheduledJobsForm: ScheduledJobsForm
scheduledJobs: EditableScheduledJob[]
scheduledJobRuntime: AdminScheduledJobRuntimeState[]
cloudtentaclesMonitorAccounts: AdminCloudtentaclesMonitorAccount[]
scheduledJobsSaving: boolean
scheduledJobRunningId: string
notificationStats: NotificationStats
@@ -99,6 +104,7 @@ defineProps<Props>()
:scheduled-jobs-form="scheduledJobsForm"
:scheduled-jobs="scheduledJobs"
:scheduled-job-runtime="scheduledJobRuntime"
:cloudtentacles-monitor-accounts="cloudtentaclesMonitorAccounts"
:scheduled-jobs-saving="scheduledJobsSaving"
:scheduled-job-running-id="scheduledJobRunningId"
:handle-scheduled-jobs-save-config="handleScheduledJobsSaveConfig"
@@ -54,4 +54,12 @@ export type EditableScheduledJob = {
cooldownSecondsAmount: number
cooldownSecondsUnit: number
assetThreshold: number
accounts: EditableScheduledJobAccount[]
}
export type EditableScheduledJobAccount = {
sourceKey: string
label: string
enabled: boolean
assetThreshold: number
}
@@ -8,6 +8,7 @@ import {
testAdminNotification,
} from '@/services/admin'
import type {
AdminCloudtentaclesMonitorAccount,
AdminNotificationConfig,
AdminNotificationTestResult,
AdminScheduledJobRuntimeState,
@@ -44,6 +45,7 @@ export function useAdminNotificationPlatform() {
})
const scheduledJobs = ref<EditableScheduledJob[]>([])
const scheduledJobRuntime = ref<AdminScheduledJobRuntimeState[]>([])
const cloudtentaclesMonitorAccounts = ref<AdminCloudtentaclesMonitorAccount[]>([])
const scheduledJobsSaving = ref(false)
const scheduledJobRunningId = ref('')
@@ -109,6 +111,7 @@ export function useAdminNotificationPlatform() {
function hydrateScheduledJobsConfig(data: AdminScheduledJobsResponse) {
scheduledJobsFilePath.value = data.filePath
scheduledJobsForm.value.enabled = data.source.enabled !== false
cloudtentaclesMonitorAccounts.value = data.cloudtentaclesAccounts || []
scheduledJobs.value = data.source.jobs.map((item) => ({
id: item.id,
type: item.type,
@@ -119,7 +122,12 @@ export function useAdminNotificationPlatform() {
cooldownSeconds: Number(item.cooldownSeconds || 1800),
cooldownSecondsAmount: resolveTimeValue(Number(item.cooldownSeconds || 1800)).amount,
cooldownSecondsUnit: resolveTimeValue(Number(item.cooldownSeconds || 1800)).unit,
assetThreshold: Number(item.config?.assetThreshold || 500),
assetThreshold: normalizeAssetThreshold(item.config?.assetThreshold, 500),
accounts: mergeScheduledJobAccounts(
item.config?.accounts || [],
data.cloudtentaclesAccounts || [],
normalizeAssetThreshold(item.config?.assetThreshold, 500),
),
}))
scheduledJobRuntime.value = data.runtime || []
}
@@ -191,7 +199,13 @@ export function useAdminNotificationPlatform() {
intervalSeconds: Number(item.intervalSeconds || 300),
cooldownSeconds: Number(item.cooldownSeconds || 1800),
config: {
assetThreshold: Number(item.assetThreshold || 500),
assetThreshold: normalizeAssetThreshold(item.assetThreshold, 500),
accounts: item.accounts.map((account) => ({
sourceKey: account.sourceKey,
label: account.label.trim(),
enabled: account.enabled,
assetThreshold: normalizeAssetThreshold(account.assetThreshold, 0),
})),
},
})),
}
@@ -284,6 +298,49 @@ export function useAdminNotificationPlatform() {
return scheduledJobRuntime.value.find((item) => item.id === jobId) || null
}
function mergeScheduledJobAccounts(
configuredAccounts: EditableScheduledJob['accounts'],
monitorAccounts: AdminCloudtentaclesMonitorAccount[],
defaultAssetThreshold: number,
) {
const configuredMap = new Map(
configuredAccounts.map((item) => [String(item.sourceKey || '').trim(), item]),
)
const merged = monitorAccounts
.filter((item) => String(item.sourceKey || '').trim())
.map((item) => {
const sourceKey = String(item.sourceKey || '').trim()
const configured = configuredMap.get(sourceKey)
return {
sourceKey,
label: String(configured?.label || item.label || sourceKey).trim(),
enabled: configured ? configured.enabled !== false : item.enabled !== false,
assetThreshold: normalizeAssetThreshold(configured?.assetThreshold, defaultAssetThreshold),
}
})
const mergedKeys = new Set(merged.map((item) => item.sourceKey))
for (const configured of configuredAccounts) {
const sourceKey = String(configured.sourceKey || '').trim()
if (!sourceKey || mergedKeys.has(sourceKey)) {
continue
}
merged.push({
sourceKey,
label: String(configured.label || sourceKey).trim(),
enabled: configured.enabled !== false,
assetThreshold: normalizeAssetThreshold(configured.assetThreshold, defaultAssetThreshold),
})
}
return merged
}
function normalizeAssetThreshold(value: unknown, fallback: number) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
}
function resolveTimeValue(seconds: number) {
const normalized = Math.max(0, Number(seconds || 0))
if (normalized >= 3600 && normalized % 3600 === 0) {
@@ -310,6 +367,7 @@ export function useAdminNotificationPlatform() {
scheduledJobsForm,
scheduledJobs,
scheduledJobRuntime,
cloudtentaclesMonitorAccounts,
scheduledJobsSaving,
scheduledJobRunningId,
notificationStats,