优化监控任务多账号配置
This commit is contained in:
@@ -8,7 +8,21 @@
|
|||||||
"intervalSeconds": 18000,
|
"intervalSeconds": 18000,
|
||||||
"cooldownSeconds": 1800,
|
"cooldownSeconds": 1800,
|
||||||
"config": {
|
"config": {
|
||||||
"assetThreshold": 500
|
"assetThreshold": 500,
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"sourceKey": "default",
|
||||||
|
"label": "默认账号",
|
||||||
|
"enabled": true,
|
||||||
|
"assetThreshold": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceKey": "account2",
|
||||||
|
"label": "备用账号 1",
|
||||||
|
"enabled": true,
|
||||||
|
"assetThreshold": 500
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
saveNotificationConfig,
|
saveNotificationConfig,
|
||||||
} from '../../notification/config-service.js'
|
} from '../../notification/config-service.js'
|
||||||
import { sendInternalNotification } from '../../notification/notification-service.js'
|
import { sendInternalNotification } from '../../notification/notification-service.js'
|
||||||
|
import { listCloudtentaclesSources } from '../../platforms/cloudtentacles/source-config-service.js'
|
||||||
|
import { getAllCloudtentaclesSessionStates } from '../../platforms/cloudtentacles/session-state-service.js'
|
||||||
import {
|
import {
|
||||||
getScheduledJobsConfig,
|
getScheduledJobsConfig,
|
||||||
getScheduledJobsFilePath,
|
getScheduledJobsFilePath,
|
||||||
@@ -14,7 +16,7 @@ import {
|
|||||||
reloadScheduledJobs,
|
reloadScheduledJobs,
|
||||||
runScheduledJobNow,
|
runScheduledJobNow,
|
||||||
} from '../../scheduler/scheduler-service.js'
|
} from '../../scheduler/scheduler-service.js'
|
||||||
import { maskSecret } from './mappers.js'
|
import { maskPhone, maskSecret } from './mappers.js'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdminNotificationConfigInput,
|
AdminNotificationConfigInput,
|
||||||
@@ -58,6 +60,7 @@ export function getAdminScheduledJobsConfig() {
|
|||||||
filePath: getScheduledJobsFilePath(),
|
filePath: getScheduledJobsFilePath(),
|
||||||
source: mapAdminScheduledJobsConfig(config),
|
source: mapAdminScheduledJobsConfig(config),
|
||||||
runtime: getScheduledJobRuntimeStates(),
|
runtime: getScheduledJobRuntimeStates(),
|
||||||
|
cloudtentaclesAccounts: listAdminCloudtentaclesMonitorAccounts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ export function updateAdminScheduledJobsConfig(payload: AdminScheduledJobsConfig
|
|||||||
filePath: getScheduledJobsFilePath(),
|
filePath: getScheduledJobsFilePath(),
|
||||||
source: mapAdminScheduledJobsConfig(saved),
|
source: mapAdminScheduledJobsConfig(saved),
|
||||||
runtime: getScheduledJobRuntimeStates(),
|
runtime: getScheduledJobRuntimeStates(),
|
||||||
|
cloudtentaclesAccounts: listAdminCloudtentaclesMonitorAccounts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +118,11 @@ function mapAdminNotificationConfig(config: JsonObject = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapAdminScheduledJobsConfig(config: JsonObject = {}) {
|
function mapAdminScheduledJobsConfig(config: JsonObject = {}) {
|
||||||
|
const cloudtentaclesAccountMap = new Map(
|
||||||
|
listAdminCloudtentaclesMonitorAccounts()
|
||||||
|
.map((item) => [item.sourceKey, item]),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled !== false,
|
enabled: config.enabled !== false,
|
||||||
jobs: (Array.isArray(config.jobs) ? config.jobs : []).map((item) => ({
|
jobs: (Array.isArray(config.jobs) ? config.jobs : []).map((item) => ({
|
||||||
@@ -123,8 +132,67 @@ function mapAdminScheduledJobsConfig(config: JsonObject = {}) {
|
|||||||
intervalSeconds: Number(item.intervalSeconds || 300),
|
intervalSeconds: Number(item.intervalSeconds || 300),
|
||||||
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
||||||
config: {
|
config: {
|
||||||
assetThreshold: Number(item.config?.assetThreshold || 500),
|
assetThreshold: normalizeNonNegativeNumber(item.config?.assetThreshold, 500),
|
||||||
|
accounts: mapScheduledJobCloudtentaclesAccounts(
|
||||||
|
item.config?.accounts,
|
||||||
|
normalizeNonNegativeNumber(item.config?.assetThreshold, 500),
|
||||||
|
cloudtentaclesAccountMap,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listAdminCloudtentaclesMonitorAccounts() {
|
||||||
|
const sourcesConfig = listCloudtentaclesSources()
|
||||||
|
const sessionsConfig = getAllCloudtentaclesSessionStates()
|
||||||
|
const sessions = sessionsConfig.sessions || {}
|
||||||
|
|
||||||
|
return (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []).map((source) => {
|
||||||
|
const sourceKey = String(source.key || '').trim()
|
||||||
|
const session = sessions[sourceKey] || {}
|
||||||
|
const label = String(source.label || source.username || sourceKey).trim() || sourceKey
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceKey,
|
||||||
|
label,
|
||||||
|
enabled: source.enabled !== false,
|
||||||
|
username: String(source.username || '').trim(),
|
||||||
|
phoneMasked: maskPhone(source.phone || session.phone),
|
||||||
|
hasToken: Boolean(String(session.token || '').trim()),
|
||||||
|
loggedInAt: String(session.loggedInAt || '').trim(),
|
||||||
|
}
|
||||||
|
}).filter((item) => item.sourceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapScheduledJobCloudtentaclesAccounts(
|
||||||
|
rawAccounts: unknown,
|
||||||
|
defaultAssetThreshold: number,
|
||||||
|
cloudtentaclesAccountMap: Map<string, JsonObject>,
|
||||||
|
) {
|
||||||
|
const accounts = Array.isArray(rawAccounts) ? rawAccounts : []
|
||||||
|
return accounts.map((item) => {
|
||||||
|
const source = isPlainObject(item) ? item : {}
|
||||||
|
const sourceKey = String(source.sourceKey || source.key || '').trim()
|
||||||
|
const option = cloudtentaclesAccountMap.get(sourceKey) || {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceKey,
|
||||||
|
label: String(source.label || option.label || sourceKey).trim(),
|
||||||
|
enabled: source.enabled !== false,
|
||||||
|
assetThreshold: normalizeNonNegativeNumber(
|
||||||
|
source.assetThreshold ?? source.threshold,
|
||||||
|
defaultAssetThreshold,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}).filter((item) => item.sourceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonNegativeNumber(value: unknown, fallback: number) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is JsonObject {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,16 +79,24 @@ export function notifyCloudtentaclesAuthExpired({
|
|||||||
errorCode = '',
|
errorCode = '',
|
||||||
message = '',
|
message = '',
|
||||||
cooldownSeconds = 600,
|
cooldownSeconds = 600,
|
||||||
|
sourceKey = '',
|
||||||
|
accountLabel = '',
|
||||||
}: JsonObject = {}) {
|
}: JsonObject = {}) {
|
||||||
|
const normalizedSourceKey = String(sourceKey || '').trim()
|
||||||
|
const normalizedAccountLabel = String(accountLabel || '').trim()
|
||||||
|
|
||||||
return notifyInternalSafely({
|
return notifyInternalSafely({
|
||||||
title: 'cloudtentacles 登录已过期',
|
title: normalizedAccountLabel
|
||||||
|
? `cloudtentacles 登录已过期:${normalizedAccountLabel}`
|
||||||
|
: 'cloudtentacles 登录已过期',
|
||||||
body: [
|
body: [
|
||||||
|
normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '',
|
||||||
`接口:${String(pathname || '').trim() || '-'}`,
|
`接口:${String(pathname || '').trim() || '-'}`,
|
||||||
`错误码:${String(errorCode || '').trim() || '-'}`,
|
`错误码:${String(errorCode || '').trim() || '-'}`,
|
||||||
`原因:${String(message || '').trim() || '登录态已失效,请到后台重新登录'}`,
|
`原因:${String(message || '').trim() || '登录态已失效,请到后台重新登录'}`,
|
||||||
].join('\n'),
|
].filter(Boolean).join('\n'),
|
||||||
category: 'cloudtentacles_auth_expired',
|
category: 'cloudtentacles_auth_expired',
|
||||||
cooldownKey: ['cloudtentacles_auth_expired', pathname, errorCode].join(':'),
|
cooldownKey: ['cloudtentacles_auth_expired', normalizedSourceKey || 'default', pathname, errorCode].join(':'),
|
||||||
cooldownMs: Number(cooldownSeconds || 600) * 1000,
|
cooldownMs: Number(cooldownSeconds || 600) * 1000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -97,16 +105,24 @@ export function notifyCloudtentaclesAssetLow({
|
|||||||
asset = 0,
|
asset = 0,
|
||||||
threshold = 500,
|
threshold = 500,
|
||||||
cooldownSeconds = 1800,
|
cooldownSeconds = 1800,
|
||||||
|
sourceKey = '',
|
||||||
|
accountLabel = '',
|
||||||
}: JsonObject = {}) {
|
}: JsonObject = {}) {
|
||||||
|
const normalizedSourceKey = String(sourceKey || '').trim()
|
||||||
|
const normalizedAccountLabel = String(accountLabel || '').trim()
|
||||||
|
|
||||||
return notifyInternalSafely({
|
return notifyInternalSafely({
|
||||||
title: '快手 Cloud 余额低于阈值',
|
title: normalizedAccountLabel
|
||||||
|
? `快手 Cloud 余额低于阈值:${normalizedAccountLabel}`
|
||||||
|
: '快手 Cloud 余额低于阈值',
|
||||||
body: [
|
body: [
|
||||||
|
normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '',
|
||||||
`当前余额:${Number(asset || 0)}`,
|
`当前余额:${Number(asset || 0)}`,
|
||||||
`提醒阈值:${Number(threshold || 0)}`,
|
`提醒阈值:${Number(threshold || 0)}`,
|
||||||
'请及时补充 cloudtentacles 余额,避免自动履约失败。',
|
'请及时补充 cloudtentacles 余额,避免自动履约失败。',
|
||||||
].join('\n'),
|
].filter(Boolean).join('\n'),
|
||||||
category: 'cloudtentacles_asset_low',
|
category: 'cloudtentacles_asset_low',
|
||||||
cooldownKey: ['cloudtentacles_asset_low', threshold].join(':'),
|
cooldownKey: ['cloudtentacles_asset_low', normalizedSourceKey || 'default', threshold].join(':'),
|
||||||
cooldownMs: Number(cooldownSeconds || 1800) * 1000,
|
cooldownMs: Number(cooldownSeconds || 1800) * 1000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
|||||||
pathname: normalizedPathname,
|
pathname: normalizedPathname,
|
||||||
errorCode,
|
errorCode,
|
||||||
message,
|
message,
|
||||||
|
sourceKey: options.sourceKey,
|
||||||
|
accountLabel: options.accountLabel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,296 @@
|
|||||||
import { getCloudtentaclesAsset } from '../platforms/cloudtentacles/catalog-service.js'
|
import { getCloudtentaclesAsset } from '../platforms/cloudtentacles/catalog-service.js'
|
||||||
import { getCloudtentaclesSourceConfig } from '../platforms/cloudtentacles/source-config-service.js'
|
import {
|
||||||
import { getCloudtentaclesSessionState } from '../platforms/cloudtentacles/session-state-service.js'
|
getCloudtentaclesSourceByKey,
|
||||||
|
listCloudtentaclesSources,
|
||||||
|
} from '../platforms/cloudtentacles/source-config-service.js'
|
||||||
|
import { getCloudtentaclesSessionStateByKey } from '../platforms/cloudtentacles/session-state-service.js'
|
||||||
import {
|
import {
|
||||||
notifyCloudtentaclesAssetLow,
|
notifyCloudtentaclesAssetLow,
|
||||||
notifyCloudtentaclesAuthExpired,
|
notifyCloudtentaclesAuthExpired,
|
||||||
} from '../notification/domain-notifications.js'
|
} from '../notification/domain-notifications.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
type CloudtentaclesHealthAccountResult = {
|
||||||
|
sourceKey: string
|
||||||
|
label: string
|
||||||
|
enabled: boolean
|
||||||
|
hasToken: boolean
|
||||||
|
ok: boolean
|
||||||
|
skipped: boolean
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
asset: number | null
|
||||||
|
threshold: number
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function runCloudtentaclesHealthJob(job: JsonObject) {
|
export async function runCloudtentaclesHealthJob(job: JsonObject) {
|
||||||
const source = getCloudtentaclesSourceConfig()
|
const accounts = resolveCloudtentaclesHealthAccounts(job)
|
||||||
const session = getCloudtentaclesSessionState()
|
|
||||||
const token = String(session.token || '').trim()
|
|
||||||
const threshold = Number(job.config?.assetThreshold || 500)
|
|
||||||
const cooldownSeconds = Number(job.cooldownSeconds || 1800)
|
const cooldownSeconds = Number(job.cooldownSeconds || 1800)
|
||||||
|
const results = await Promise.all(
|
||||||
|
accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)),
|
||||||
|
)
|
||||||
|
const checkedResults = results.filter((item) => !item.skipped)
|
||||||
|
const failedResults = checkedResults.filter((item) => item.ok === false)
|
||||||
|
const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low')
|
||||||
|
const okResults = checkedResults.filter((item) => item.status === 'ok')
|
||||||
|
const assetValues = checkedResults
|
||||||
|
.map((item) => item.asset)
|
||||||
|
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||||
|
const summary = buildCloudtentaclesHealthSummary(results)
|
||||||
|
const status = resolveCloudtentaclesHealthStatus(results)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: failedResults.length === 0,
|
||||||
|
status,
|
||||||
|
message: summary,
|
||||||
|
accountCount: results.length,
|
||||||
|
checkedCount: checkedResults.length,
|
||||||
|
okCount: okResults.length,
|
||||||
|
lowAssetCount: lowAssetResults.length,
|
||||||
|
failedCount: failedResults.length,
|
||||||
|
asset: assetValues.length > 0
|
||||||
|
? Math.min(...assetValues)
|
||||||
|
: null,
|
||||||
|
threshold: checkedResults.length > 0
|
||||||
|
? Math.max(...checkedResults.map((item) => Number(item.threshold || 0)))
|
||||||
|
: normalizeNonNegativeInteger(job.config?.assetThreshold, 500),
|
||||||
|
accounts: results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCloudtentaclesHealthAccount(
|
||||||
|
account: JsonObject,
|
||||||
|
cooldownSeconds: number,
|
||||||
|
): Promise<CloudtentaclesHealthAccountResult> {
|
||||||
|
const sourceKey = String(account.sourceKey || '').trim() || 'default'
|
||||||
|
const source = getCloudtentaclesSourceByKey(sourceKey)
|
||||||
|
const session = getCloudtentaclesSessionStateByKey(sourceKey) || {}
|
||||||
|
const label = String(account.label || source?.label || source?.username || sourceKey).trim() || sourceKey
|
||||||
|
const threshold = normalizeNonNegativeInteger(account.assetThreshold, 500)
|
||||||
|
const checkedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return {
|
||||||
|
sourceKey,
|
||||||
|
label,
|
||||||
|
enabled: account.enabled !== false,
|
||||||
|
hasToken: false,
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
status: 'source_missing',
|
||||||
|
message: `账号 ${label} 不存在`,
|
||||||
|
asset: null,
|
||||||
|
threshold,
|
||||||
|
checkedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.enabled === false || source.enabled === false) {
|
||||||
|
return {
|
||||||
|
sourceKey,
|
||||||
|
label,
|
||||||
|
enabled: false,
|
||||||
|
hasToken: Boolean(String(session.token || '').trim()),
|
||||||
|
ok: true,
|
||||||
|
skipped: true,
|
||||||
|
status: 'disabled',
|
||||||
|
message: `账号 ${label} 已停用`,
|
||||||
|
asset: null,
|
||||||
|
threshold,
|
||||||
|
checkedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(session.token || '').trim()
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
await notifyCloudtentaclesAuthExpired({
|
await notifyCloudtentaclesAuthExpired({
|
||||||
pathname: '/user/get_asset',
|
pathname: '/user/get_asset',
|
||||||
errorCode: 'cloudtentacles_token_missing',
|
errorCode: 'cloudtentacles_token_missing',
|
||||||
message: '当前 cloudtentacles 没有可用 token,请到后台重新登录',
|
message: `账号 ${label} 没有可用 token,请到后台重新登录`,
|
||||||
cooldownSeconds,
|
cooldownSeconds,
|
||||||
|
sourceKey,
|
||||||
|
accountLabel: label,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 'auth_missing',
|
status: 'auth_missing',
|
||||||
message: 'cloudtentacles token 缺失',
|
message: `账号 ${label} token 缺失`,
|
||||||
asset: null,
|
asset: null,
|
||||||
threshold,
|
threshold,
|
||||||
|
sourceKey,
|
||||||
|
label,
|
||||||
|
enabled: true,
|
||||||
|
hasToken: false,
|
||||||
|
skipped: false,
|
||||||
|
checkedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetResult = await getCloudtentaclesAsset({
|
try {
|
||||||
baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176',
|
const assetResult = await getCloudtentaclesAsset({
|
||||||
token,
|
sourceKey,
|
||||||
deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-',
|
accountLabel: label,
|
||||||
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
|
baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176',
|
||||||
})
|
token,
|
||||||
const asset = Number(assetResult.asset || 0)
|
deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-',
|
||||||
|
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
|
||||||
|
})
|
||||||
|
const asset = Number(assetResult.asset || 0)
|
||||||
|
|
||||||
if (asset < threshold) {
|
if (asset < threshold) {
|
||||||
await notifyCloudtentaclesAssetLow({
|
await notifyCloudtentaclesAssetLow({
|
||||||
|
asset,
|
||||||
|
threshold,
|
||||||
|
cooldownSeconds,
|
||||||
|
sourceKey,
|
||||||
|
accountLabel: label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: asset < threshold ? 'asset_low' : 'ok',
|
||||||
|
message: asset < threshold
|
||||||
|
? `账号 ${label} 余额 ${asset} 低于阈值 ${threshold}`
|
||||||
|
: `账号 ${label} 余额 ${asset} 正常`,
|
||||||
asset,
|
asset,
|
||||||
threshold,
|
threshold,
|
||||||
cooldownSeconds,
|
sourceKey,
|
||||||
})
|
label,
|
||||||
}
|
enabled: true,
|
||||||
|
hasToken: true,
|
||||||
return {
|
skipped: false,
|
||||||
ok: true,
|
checkedAt,
|
||||||
status: asset < threshold ? 'asset_low' : 'ok',
|
}
|
||||||
message: asset < threshold ? `余额 ${asset} 低于阈值 ${threshold}` : `余额 ${asset} 正常`,
|
} catch (error) {
|
||||||
asset,
|
const message = error instanceof Error ? error.message : String(error || '余额检查失败')
|
||||||
threshold,
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
message: `账号 ${label} 检查失败:${message}`,
|
||||||
|
asset: null,
|
||||||
|
threshold,
|
||||||
|
sourceKey,
|
||||||
|
label,
|
||||||
|
enabled: true,
|
||||||
|
hasToken: true,
|
||||||
|
skipped: false,
|
||||||
|
checkedAt,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCloudtentaclesHealthAccounts(job: JsonObject) {
|
||||||
|
const config = isPlainObject(job.config) ? job.config : {}
|
||||||
|
const rawAccounts = Array.isArray(config.accounts) ? config.accounts : []
|
||||||
|
const defaultThreshold = normalizeNonNegativeInteger(config.assetThreshold, 500)
|
||||||
|
const sourceConfig = listCloudtentaclesSources()
|
||||||
|
const sourceMap = new Map(
|
||||||
|
(Array.isArray(sourceConfig.sources) ? sourceConfig.sources : [])
|
||||||
|
.map((source) => [String(source.key || '').trim(), source]),
|
||||||
|
)
|
||||||
|
const normalizedAccounts = rawAccounts
|
||||||
|
.map((item) => normalizeCloudtentaclesHealthAccount(item, defaultThreshold, sourceMap))
|
||||||
|
.filter(Boolean) as JsonObject[]
|
||||||
|
|
||||||
|
if (normalizedAccounts.length > 0) {
|
||||||
|
return dedupeAccounts(normalizedAccounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
sourceKey: 'default',
|
||||||
|
label: sourceMap.get('default')?.label || '默认账号',
|
||||||
|
enabled: true,
|
||||||
|
assetThreshold: defaultThreshold,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudtentaclesHealthAccount(
|
||||||
|
rawValue: unknown,
|
||||||
|
defaultThreshold: number,
|
||||||
|
sourceMap: Map<string, JsonObject>,
|
||||||
|
) {
|
||||||
|
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||||
|
const sourceKey = String(source.sourceKey || source.key || '').trim()
|
||||||
|
if (!sourceKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceConfig = sourceMap.get(sourceKey) || {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceKey,
|
||||||
|
label: String(source.label || sourceConfig.label || sourceConfig.username || sourceKey).trim(),
|
||||||
|
enabled: source.enabled !== false,
|
||||||
|
assetThreshold: normalizeNonNegativeInteger(
|
||||||
|
source.assetThreshold ?? source.threshold,
|
||||||
|
defaultThreshold,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeAccounts(accounts: JsonObject[]) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return accounts.filter((item) => {
|
||||||
|
const sourceKey = String(item.sourceKey || '').trim()
|
||||||
|
if (!sourceKey || seen.has(sourceKey)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen.add(sourceKey)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCloudtentaclesHealthSummary(results: CloudtentaclesHealthAccountResult[]) {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return '未配置 cloudtentacles 监控账号'
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedResults = results.filter((item) => !item.skipped)
|
||||||
|
const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low')
|
||||||
|
const failedResults = checkedResults.filter((item) => item.ok === false)
|
||||||
|
const disabledCount = results.length - checkedResults.length
|
||||||
|
|
||||||
|
if (checkedResults.length === 0) {
|
||||||
|
return `所有账号均已停用,共 ${disabledCount} 个`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedResults.length > 0 || lowAssetResults.length > 0) {
|
||||||
|
return [
|
||||||
|
`检查 ${checkedResults.length} 个账号`,
|
||||||
|
lowAssetResults.length > 0 ? `余额预警 ${lowAssetResults.length} 个` : '',
|
||||||
|
failedResults.length > 0 ? `异常 ${failedResults.length} 个` : '',
|
||||||
|
disabledCount > 0 ? `停用 ${disabledCount} 个` : '',
|
||||||
|
].filter(Boolean).join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
return `检查 ${checkedResults.length} 个账号,余额均正常${disabledCount > 0 ? `,停用 ${disabledCount} 个` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCloudtentaclesHealthStatus(results: CloudtentaclesHealthAccountResult[]) {
|
||||||
|
const checkedResults = results.filter((item) => !item.skipped)
|
||||||
|
if (checkedResults.length === 0) {
|
||||||
|
return 'skipped'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkedResults.some((item) => ['auth_missing', 'source_missing', 'failed'].includes(item.status))) {
|
||||||
|
return 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkedResults.some((item) => item.status === 'asset_low')) {
|
||||||
|
return 'asset_low'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonNegativeInteger(value: unknown, fallback: number) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is JsonObject {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { normalizeScheduledJobsConfig } from './config-service.js'
|
||||||
|
|
||||||
|
test('normalizeScheduledJobsConfig 将旧版 cloudtentacles 阈值迁移为默认账号配置', () => {
|
||||||
|
const config = normalizeScheduledJobsConfig({
|
||||||
|
enabled: true,
|
||||||
|
jobs: [
|
||||||
|
{
|
||||||
|
id: 'cloudtentacles-health',
|
||||||
|
type: 'cloudtentacles_health',
|
||||||
|
enabled: true,
|
||||||
|
intervalSeconds: 18000,
|
||||||
|
cooldownSeconds: 1800,
|
||||||
|
config: {
|
||||||
|
assetThreshold: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(config.jobs[0], {
|
||||||
|
id: 'cloudtentacles-health',
|
||||||
|
type: 'cloudtentacles_health',
|
||||||
|
enabled: true,
|
||||||
|
intervalSeconds: 18000,
|
||||||
|
cooldownSeconds: 1800,
|
||||||
|
config: {
|
||||||
|
assetThreshold: 100,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
sourceKey: 'default',
|
||||||
|
label: '',
|
||||||
|
enabled: true,
|
||||||
|
assetThreshold: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizeScheduledJobsConfig 支持多账号去重并保留 0 阈值', () => {
|
||||||
|
const config = normalizeScheduledJobsConfig({
|
||||||
|
jobs: [
|
||||||
|
{
|
||||||
|
id: 'cloudtentacles-health',
|
||||||
|
type: 'cloudtentacles_health',
|
||||||
|
enabled: true,
|
||||||
|
intervalSeconds: 30,
|
||||||
|
cooldownSeconds: 10,
|
||||||
|
config: {
|
||||||
|
assetThreshold: 500,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
sourceKey: 'default',
|
||||||
|
label: '默认账号',
|
||||||
|
enabled: true,
|
||||||
|
assetThreshold: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceKey: 'default',
|
||||||
|
label: '重复账号',
|
||||||
|
enabled: false,
|
||||||
|
assetThreshold: 999,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceKey: 'account2',
|
||||||
|
enabled: false,
|
||||||
|
threshold: 200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(config.jobs[0].config.accounts, [
|
||||||
|
{
|
||||||
|
sourceKey: 'default',
|
||||||
|
label: '默认账号',
|
||||||
|
enabled: true,
|
||||||
|
assetThreshold: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceKey: 'account2',
|
||||||
|
label: '',
|
||||||
|
enabled: false,
|
||||||
|
assetThreshold: 200,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
assert.equal(config.jobs[0].intervalSeconds, 60)
|
||||||
|
assert.equal(config.jobs[0].cooldownSeconds, 60)
|
||||||
|
})
|
||||||
@@ -34,7 +34,7 @@ function loadScheduledJobsConfigFromFile() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeScheduledJobsConfig(rawValue: unknown) {
|
export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||||
const rawJobs = Array.isArray(source.jobs) ? source.jobs : []
|
const rawJobs = Array.isArray(source.jobs) ? source.jobs : []
|
||||||
const jobs = rawJobs.map((item) => normalizeScheduledJob(item)).filter(Boolean)
|
const jobs = rawJobs.map((item) => normalizeScheduledJob(item)).filter(Boolean)
|
||||||
@@ -65,6 +65,7 @@ function normalizeScheduledJob(rawValue: unknown) {
|
|||||||
|
|
||||||
function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) {
|
function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) {
|
||||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||||
|
const defaultAssetThreshold = normalizeRangeInteger(config.assetThreshold, 500, 0, 999999)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: CLOUDTENTACLES_HEALTH_JOB_ID,
|
id: CLOUDTENTACLES_HEALTH_JOB_ID,
|
||||||
@@ -73,11 +74,57 @@ function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) {
|
|||||||
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 86400),
|
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 86400),
|
||||||
cooldownSeconds: normalizeRangeInteger(rawValue.cooldownSeconds, 1800, 60, 86400),
|
cooldownSeconds: normalizeRangeInteger(rawValue.cooldownSeconds, 1800, 60, 86400),
|
||||||
config: {
|
config: {
|
||||||
assetThreshold: normalizeRangeInteger(config.assetThreshold, 500, 0, 999999),
|
assetThreshold: defaultAssetThreshold,
|
||||||
|
accounts: normalizeCloudtentaclesHealthAccounts(config, defaultAssetThreshold),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCloudtentaclesHealthAccounts(config: JsonObject, defaultAssetThreshold: number) {
|
||||||
|
const rawAccounts = Array.isArray(config.accounts)
|
||||||
|
? config.accounts
|
||||||
|
: Array.isArray(config.sourceKeys)
|
||||||
|
? config.sourceKeys.map((sourceKey) => ({ sourceKey }))
|
||||||
|
: []
|
||||||
|
const accounts = rawAccounts
|
||||||
|
.map((item) => normalizeCloudtentaclesHealthAccount(item, defaultAssetThreshold))
|
||||||
|
.filter(Boolean) as ReturnType<typeof normalizeCloudtentaclesHealthAccount>[]
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
accounts.push(normalizeCloudtentaclesHealthAccount({
|
||||||
|
sourceKey: config.sourceKey || 'default',
|
||||||
|
assetThreshold: defaultAssetThreshold,
|
||||||
|
}, defaultAssetThreshold))
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return accounts.filter((item) => {
|
||||||
|
const sourceKey = String(item?.sourceKey || '').trim()
|
||||||
|
if (!sourceKey || seen.has(sourceKey)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen.add(sourceKey)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudtentaclesHealthAccount(rawValue: unknown, defaultAssetThreshold: number) {
|
||||||
|
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||||
|
const sourceKey = String(source.sourceKey || source.key || '').trim() || 'default'
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceKey,
|
||||||
|
label: String(source.label || '').trim(),
|
||||||
|
enabled: source.enabled !== false,
|
||||||
|
assetThreshold: normalizeRangeInteger(
|
||||||
|
source.assetThreshold ?? source.threshold,
|
||||||
|
defaultAssetThreshold,
|
||||||
|
0,
|
||||||
|
999999,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createDefaultScheduledJobsConfig() {
|
function createDefaultScheduledJobsConfig() {
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -96,6 +143,14 @@ function createDefaultCloudtentaclesHealthJob() {
|
|||||||
cooldownSeconds: 1800,
|
cooldownSeconds: 1800,
|
||||||
config: {
|
config: {
|
||||||
assetThreshold: 500,
|
assetThreshold: 500,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
sourceKey: 'default',
|
||||||
|
label: '默认账号',
|
||||||
|
enabled: true,
|
||||||
|
assetThreshold: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ async function runJob(job: JsonObject, { manual = false }: { manual?: boolean }
|
|||||||
lastMessage: result?.message || '执行完成',
|
lastMessage: result?.message || '执行完成',
|
||||||
lastAsset: typeof result?.asset === 'number' ? result.asset : null,
|
lastAsset: typeof result?.asset === 'number' ? result.asset : null,
|
||||||
lastThreshold: typeof result?.threshold === 'number' ? result.threshold : null,
|
lastThreshold: typeof result?.threshold === 'number' ? result.threshold : null,
|
||||||
|
lastAccounts: Array.isArray(result?.accounts) ? result.accounts : [],
|
||||||
|
lastAccountCount: Number(result?.accountCount || 0),
|
||||||
|
lastCheckedCount: Number(result?.checkedCount || 0),
|
||||||
|
lastOkCount: Number(result?.okCount || 0),
|
||||||
|
lastLowAssetCount: Number(result?.lowAssetCount || 0),
|
||||||
|
lastFailedCount: Number(result?.failedCount || 0),
|
||||||
lastManual: manual,
|
lastManual: manual,
|
||||||
})
|
})
|
||||||
logInfo('[scheduler]', '定时任务执行完成', {
|
logInfo('[scheduler]', '定时任务执行完成', {
|
||||||
@@ -180,6 +186,12 @@ function updateJobState(jobId: string, patch: JsonObject) {
|
|||||||
lastMessage: '',
|
lastMessage: '',
|
||||||
lastAsset: null,
|
lastAsset: null,
|
||||||
lastThreshold: null,
|
lastThreshold: null,
|
||||||
|
lastAccounts: [],
|
||||||
|
lastAccountCount: 0,
|
||||||
|
lastCheckedCount: 0,
|
||||||
|
lastOkCount: 0,
|
||||||
|
lastLowAssetCount: 0,
|
||||||
|
lastFailedCount: 0,
|
||||||
lastManual: false,
|
lastManual: false,
|
||||||
}
|
}
|
||||||
jobStates.set(jobId, {
|
jobStates.set(jobId, {
|
||||||
|
|||||||
@@ -139,6 +139,14 @@ export type AdminScheduledJobInput = {
|
|||||||
cooldownSeconds?: number | string
|
cooldownSeconds?: number | string
|
||||||
config?: {
|
config?: {
|
||||||
assetThreshold?: number | string
|
assetThreshold?: number | string
|
||||||
|
accounts?: Array<{
|
||||||
|
sourceKey?: string
|
||||||
|
key?: string
|
||||||
|
label?: string
|
||||||
|
enabled?: boolean
|
||||||
|
assetThreshold?: number | string
|
||||||
|
threshold?: number | string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export type {
|
|||||||
AdminNotificationConfig,
|
AdminNotificationConfig,
|
||||||
AdminNotificationSendResult,
|
AdminNotificationSendResult,
|
||||||
AdminNotificationTestResult,
|
AdminNotificationTestResult,
|
||||||
|
AdminCloudtentaclesMonitorAccount,
|
||||||
|
AdminScheduledJobAccountRuntime,
|
||||||
|
AdminScheduledJobCloudtentaclesAccount,
|
||||||
AdminScheduledJobItem,
|
AdminScheduledJobItem,
|
||||||
AdminScheduledJobsConfig,
|
AdminScheduledJobsConfig,
|
||||||
AdminScheduledJobRuntimeState,
|
AdminScheduledJobRuntimeState,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export type {
|
|||||||
} from './notifications'
|
} from './notifications'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
AdminCloudtentaclesMonitorAccount,
|
||||||
|
AdminScheduledJobAccountRuntime,
|
||||||
|
AdminScheduledJobCloudtentaclesAccount,
|
||||||
AdminScheduledJobItem,
|
AdminScheduledJobItem,
|
||||||
AdminScheduledJobsConfig,
|
AdminScheduledJobsConfig,
|
||||||
AdminScheduledJobRuntimeState,
|
AdminScheduledJobRuntimeState,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
export interface AdminScheduledJobCloudtentaclesAccount {
|
||||||
|
sourceKey: string
|
||||||
|
label: string
|
||||||
|
enabled: boolean
|
||||||
|
assetThreshold: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminScheduledJobItem {
|
export interface AdminScheduledJobItem {
|
||||||
id: string
|
id: string
|
||||||
type: string
|
type: string
|
||||||
@@ -6,6 +13,7 @@ export interface AdminScheduledJobItem {
|
|||||||
cooldownSeconds: number
|
cooldownSeconds: number
|
||||||
config: {
|
config: {
|
||||||
assetThreshold: number
|
assetThreshold: number
|
||||||
|
accounts: AdminScheduledJobCloudtentaclesAccount[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +33,42 @@ export interface AdminScheduledJobRuntimeState {
|
|||||||
lastMessage: string
|
lastMessage: string
|
||||||
lastAsset: number | null
|
lastAsset: number | null
|
||||||
lastThreshold: number | null
|
lastThreshold: number | null
|
||||||
|
lastAccounts: AdminScheduledJobAccountRuntime[]
|
||||||
|
lastAccountCount: number
|
||||||
|
lastCheckedCount: number
|
||||||
|
lastOkCount: number
|
||||||
|
lastLowAssetCount: number
|
||||||
|
lastFailedCount: number
|
||||||
lastManual: boolean
|
lastManual: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminScheduledJobAccountRuntime {
|
||||||
|
sourceKey: string
|
||||||
|
label: string
|
||||||
|
enabled: boolean
|
||||||
|
hasToken: boolean
|
||||||
|
ok: boolean
|
||||||
|
skipped: boolean
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
asset: number | null
|
||||||
|
threshold: number
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCloudtentaclesMonitorAccount {
|
||||||
|
sourceKey: string
|
||||||
|
label: string
|
||||||
|
enabled: boolean
|
||||||
|
username: string
|
||||||
|
phoneMasked: string
|
||||||
|
hasToken: boolean
|
||||||
|
loggedInAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminScheduledJobsResponse {
|
export interface AdminScheduledJobsResponse {
|
||||||
filePath: string
|
filePath: string
|
||||||
source: AdminScheduledJobsConfig
|
source: AdminScheduledJobsConfig
|
||||||
runtime: AdminScheduledJobRuntimeState[]
|
runtime: AdminScheduledJobRuntimeState[]
|
||||||
|
cloudtentaclesAccounts: AdminCloudtentaclesMonitorAccount[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const {
|
|||||||
scheduledJobsForm,
|
scheduledJobsForm,
|
||||||
scheduledJobs,
|
scheduledJobs,
|
||||||
scheduledJobRuntime,
|
scheduledJobRuntime,
|
||||||
|
cloudtentaclesMonitorAccounts,
|
||||||
scheduledJobsSaving,
|
scheduledJobsSaving,
|
||||||
scheduledJobRunningId,
|
scheduledJobRunningId,
|
||||||
notificationStats,
|
notificationStats,
|
||||||
@@ -368,6 +369,7 @@ onMounted(loadConfigs)
|
|||||||
:scheduled-jobs-form="scheduledJobsForm"
|
:scheduled-jobs-form="scheduledJobsForm"
|
||||||
:scheduled-jobs="scheduledJobs"
|
:scheduled-jobs="scheduledJobs"
|
||||||
:scheduled-job-runtime="scheduledJobRuntime"
|
:scheduled-job-runtime="scheduledJobRuntime"
|
||||||
|
:cloudtentacles-monitor-accounts="cloudtentaclesMonitorAccounts"
|
||||||
:scheduled-jobs-saving="scheduledJobsSaving"
|
:scheduled-jobs-saving="scheduledJobsSaving"
|
||||||
:scheduled-job-running-id="scheduledJobRunningId"
|
:scheduled-job-running-id="scheduledJobRunningId"
|
||||||
:notification-stats="notificationStats"
|
:notification-stats="notificationStats"
|
||||||
|
|||||||
+394
-140
@@ -1,7 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AdminScheduledJobRuntimeState } from '@/types/admin'
|
import { computed } from 'vue'
|
||||||
import type { EditableScheduledJob } from '../../composables/types'
|
|
||||||
|
import type {
|
||||||
|
AdminCloudtentaclesMonitorAccount,
|
||||||
|
AdminScheduledJobAccountRuntime,
|
||||||
|
AdminScheduledJobRuntimeState,
|
||||||
|
} from '@/types/admin'
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import type {
|
||||||
|
EditableScheduledJob,
|
||||||
|
EditableScheduledJobAccount,
|
||||||
|
} from '../../composables/types'
|
||||||
|
|
||||||
const timeUnitOptions = [
|
const timeUnitOptions = [
|
||||||
{ label: '秒', value: 1 },
|
{ label: '秒', value: 1 },
|
||||||
@@ -9,6 +18,22 @@ const timeUnitOptions = [
|
|||||||
{ label: '小时', value: 3600 },
|
{ 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) {
|
function formatJobType(type: string) {
|
||||||
if (type === 'cloudtentacles_health') return 'cloudtentacles 健康检查'
|
if (type === 'cloudtentacles_health') return 'cloudtentacles 健康检查'
|
||||||
return type || '-'
|
return type || '-'
|
||||||
@@ -69,22 +94,55 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
import { computed } from 'vue'
|
function getMonitorAccount(sourceKey: string) {
|
||||||
|
return props.cloudtentaclesMonitorAccounts.find((item) => item.sourceKey === sourceKey) || null
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -94,7 +152,7 @@ defineProps<Props>()
|
|||||||
<div>
|
<div>
|
||||||
<span class="card-title">监控任务</span>
|
<span class="card-title">监控任务</span>
|
||||||
<p class="card-desc">
|
<p class="card-desc">
|
||||||
间隔表示多久检查一次;冷却表示同一个异常在这段时间内最多通知一次。
|
监控任务会按账号分别检查 cloudtentacles 登录态和余额;每个账号可设置独立阈值。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
@@ -108,116 +166,213 @@ defineProps<Props>()
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-form label-width="auto" label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-switch v-model="scheduledJobsForm.enabled" active-text="启用监控任务系统" />
|
<el-switch v-model="scheduledJobsForm.enabled" active-text="启用监控任务系统" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-table :data="scheduledJobs" stripe size="small" class="jobs-table">
|
<div class="job-list">
|
||||||
<el-table-column label="任务" min-width="200">
|
<section v-for="job in scheduledJobs" :key="job.id" class="job-panel">
|
||||||
<template #default="{ row: job }">
|
<div class="job-panel-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-600">{{ formatJobType(job.type) }}</div>
|
<div class="job-title-row">
|
||||||
<el-switch v-model="job.enabled" size="small" active-text="启用" class="job-switch" />
|
<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>
|
</div>
|
||||||
</template>
|
<div class="job-actions">
|
||||||
</el-table-column>
|
<el-switch v-model="job.enabled" active-text="启用任务" />
|
||||||
<el-table-column label="参数" min-width="320">
|
<el-button
|
||||||
<template #default="{ row: job }">
|
:loading="scheduledJobRunningId === job.id"
|
||||||
<div class="job-params">
|
size="small"
|
||||||
<div class="param-row">
|
@click="handleScheduledJobRunNow(job.id)"
|
||||||
<span class="param-label">检查间隔</span>
|
>
|
||||||
|
立即检查
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="job-settings">
|
||||||
|
<label class="duration-field">
|
||||||
|
<span class="param-label">检查间隔</span>
|
||||||
|
<span class="duration-controls">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model.number="bindDuration(job, 'intervalSeconds').value"
|
v-model.number="bindDuration(job, 'intervalSeconds').value"
|
||||||
:min="1"
|
:min="1"
|
||||||
class="mini-input"
|
class="duration-input"
|
||||||
controls-position="right"
|
controls-position="right"
|
||||||
/>
|
/>
|
||||||
<el-select
|
<el-select
|
||||||
v-model.number="bindDurationUnit(job, 'intervalSeconds').value"
|
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 }}
|
{{ unit.label }}
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
<span class="param-hint">每 {{ formatDuration(job.intervalSeconds) }} 检查一次</span>
|
</span>
|
||||||
</div>
|
<span class="param-hint">每 {{ formatDuration(job.intervalSeconds) }} 检查一次</span>
|
||||||
<div class="param-row">
|
</label>
|
||||||
<span class="param-label">通知冷却</span>
|
|
||||||
|
<label class="duration-field">
|
||||||
|
<span class="param-label">通知冷却</span>
|
||||||
|
<span class="duration-controls">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model.number="bindDuration(job, 'cooldownSeconds').value"
|
v-model.number="bindDuration(job, 'cooldownSeconds').value"
|
||||||
:min="1"
|
:min="1"
|
||||||
class="mini-input"
|
class="duration-input"
|
||||||
controls-position="right"
|
controls-position="right"
|
||||||
/>
|
/>
|
||||||
<el-select
|
<el-select
|
||||||
v-model.number="bindDurationUnit(job, 'cooldownSeconds').value"
|
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 }}
|
{{ unit.label }}
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
<span class="param-hint"
|
</span>
|
||||||
>同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span
|
<span class="param-hint"
|
||||||
>
|
>同一账号同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span
|
||||||
</div>
|
>
|
||||||
<div class="param-row">
|
</label>
|
||||||
<span class="param-label">余额阈值</span>
|
</div>
|
||||||
<el-input-number
|
|
||||||
v-model.number="job.assetThreshold"
|
<div class="runtime-summary">
|
||||||
:min="0"
|
<div class="summary-item">
|
||||||
class="mini-input"
|
<span class="summary-label">任务状态</span>
|
||||||
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">
|
|
||||||
<el-tag
|
<el-tag
|
||||||
:type="getScheduledJobRuntime(job.id)?.lastStatus === 'success' ? 'success' : 'info'"
|
:type="resolveStatusType(getScheduledJobRuntime(job.id)?.lastStatus || 'pending')"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{{ getScheduledJobRuntime(job.id)?.lastStatus || 'pending' }}
|
{{ formatStatus(getScheduledJobRuntime(job.id)?.lastStatus || 'pending') }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<span class="param-hint">{{ getScheduledJobRuntime(job.id)?.lastMessage || '-' }}</span>
|
</div>
|
||||||
<span class="param-hint"
|
<div class="summary-item">
|
||||||
>余额:{{ getScheduledJobRuntime(job.id)?.lastAsset ?? '-' }}</span
|
<span class="summary-label">账号</span>
|
||||||
>
|
<span class="summary-value"
|
||||||
<span class="param-hint"
|
><strong>{{ getScheduledJobRuntime(job.id)?.lastCheckedCount ?? 0 }}</strong>
|
||||||
>上次:{{
|
<span class="summary-muted">/ {{ job.accounts.length }} 已检查</span></span
|
||||||
formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '')
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
<span class="param-hint"
|
|
||||||
>下次:{{
|
|
||||||
formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '')
|
|
||||||
}}</span
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div class="summary-item">
|
||||||
</el-table-column>
|
<span class="summary-label">预警</span>
|
||||||
<el-table-column label="操作" width="110" align="center">
|
<span class="summary-value"
|
||||||
<template #default="{ row: job }">
|
><strong>{{ getScheduledJobRuntime(job.id)?.lastLowAssetCount ?? 0 }}</strong></span
|
||||||
<el-button
|
>
|
||||||
:loading="scheduledJobRunningId === job.id"
|
</div>
|
||||||
size="small"
|
<div class="summary-item">
|
||||||
@click="handleScheduledJobRunNow(job.id)"
|
<span class="summary-label">异常</span>
|
||||||
>
|
<span class="summary-value"
|
||||||
立即检查
|
><strong>{{ getScheduledJobRuntime(job.id)?.lastFailedCount ?? 0 }}</strong></span
|
||||||
</el-button>
|
>
|
||||||
</template>
|
</div>
|
||||||
</el-table-column>
|
<div class="summary-item">
|
||||||
<template #empty>
|
<span class="summary-label">上次</span>
|
||||||
<el-empty description="暂无可配置的监控任务。" :image-size="60" />
|
<span class="summary-value">{{
|
||||||
</template>
|
formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '')
|
||||||
</el-table>
|
}}</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>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -226,69 +381,168 @@ defineProps<Props>()
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions,
|
||||||
|
.job-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-wrap: wrap;
|
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;
|
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);
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.param-label {
|
.job-subtitle,
|
||||||
font-size: var(--text-xs);
|
.param-hint,
|
||||||
color: var(--text-secondary);
|
.summary-muted,
|
||||||
font-weight: 600;
|
.account-meta {
|
||||||
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 {
|
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-muted);
|
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;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
font-size: var(--text-xs);
|
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>
|
</style>
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AdminNotificationTestResult, AdminScheduledJobRuntimeState } from '@/types/admin'
|
import type {
|
||||||
|
AdminCloudtentaclesMonitorAccount,
|
||||||
|
AdminNotificationTestResult,
|
||||||
|
AdminScheduledJobRuntimeState,
|
||||||
|
} from '@/types/admin'
|
||||||
import type {
|
import type {
|
||||||
EditableNotificationRecipient,
|
EditableNotificationRecipient,
|
||||||
EditableScheduledJob,
|
EditableScheduledJob,
|
||||||
@@ -51,6 +55,7 @@ type Props = {
|
|||||||
scheduledJobsForm: ScheduledJobsForm
|
scheduledJobsForm: ScheduledJobsForm
|
||||||
scheduledJobs: EditableScheduledJob[]
|
scheduledJobs: EditableScheduledJob[]
|
||||||
scheduledJobRuntime: AdminScheduledJobRuntimeState[]
|
scheduledJobRuntime: AdminScheduledJobRuntimeState[]
|
||||||
|
cloudtentaclesMonitorAccounts: AdminCloudtentaclesMonitorAccount[]
|
||||||
scheduledJobsSaving: boolean
|
scheduledJobsSaving: boolean
|
||||||
scheduledJobRunningId: string
|
scheduledJobRunningId: string
|
||||||
notificationStats: NotificationStats
|
notificationStats: NotificationStats
|
||||||
@@ -99,6 +104,7 @@ defineProps<Props>()
|
|||||||
:scheduled-jobs-form="scheduledJobsForm"
|
:scheduled-jobs-form="scheduledJobsForm"
|
||||||
:scheduled-jobs="scheduledJobs"
|
:scheduled-jobs="scheduledJobs"
|
||||||
:scheduled-job-runtime="scheduledJobRuntime"
|
:scheduled-job-runtime="scheduledJobRuntime"
|
||||||
|
:cloudtentacles-monitor-accounts="cloudtentaclesMonitorAccounts"
|
||||||
:scheduled-jobs-saving="scheduledJobsSaving"
|
:scheduled-jobs-saving="scheduledJobsSaving"
|
||||||
:scheduled-job-running-id="scheduledJobRunningId"
|
:scheduled-job-running-id="scheduledJobRunningId"
|
||||||
:handle-scheduled-jobs-save-config="handleScheduledJobsSaveConfig"
|
:handle-scheduled-jobs-save-config="handleScheduledJobsSaveConfig"
|
||||||
|
|||||||
@@ -54,4 +54,12 @@ export type EditableScheduledJob = {
|
|||||||
cooldownSecondsAmount: number
|
cooldownSecondsAmount: number
|
||||||
cooldownSecondsUnit: number
|
cooldownSecondsUnit: number
|
||||||
assetThreshold: number
|
assetThreshold: number
|
||||||
|
accounts: EditableScheduledJobAccount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditableScheduledJobAccount = {
|
||||||
|
sourceKey: string
|
||||||
|
label: string
|
||||||
|
enabled: boolean
|
||||||
|
assetThreshold: number
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-2
@@ -8,6 +8,7 @@ import {
|
|||||||
testAdminNotification,
|
testAdminNotification,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import type {
|
import type {
|
||||||
|
AdminCloudtentaclesMonitorAccount,
|
||||||
AdminNotificationConfig,
|
AdminNotificationConfig,
|
||||||
AdminNotificationTestResult,
|
AdminNotificationTestResult,
|
||||||
AdminScheduledJobRuntimeState,
|
AdminScheduledJobRuntimeState,
|
||||||
@@ -44,6 +45,7 @@ export function useAdminNotificationPlatform() {
|
|||||||
})
|
})
|
||||||
const scheduledJobs = ref<EditableScheduledJob[]>([])
|
const scheduledJobs = ref<EditableScheduledJob[]>([])
|
||||||
const scheduledJobRuntime = ref<AdminScheduledJobRuntimeState[]>([])
|
const scheduledJobRuntime = ref<AdminScheduledJobRuntimeState[]>([])
|
||||||
|
const cloudtentaclesMonitorAccounts = ref<AdminCloudtentaclesMonitorAccount[]>([])
|
||||||
const scheduledJobsSaving = ref(false)
|
const scheduledJobsSaving = ref(false)
|
||||||
const scheduledJobRunningId = ref('')
|
const scheduledJobRunningId = ref('')
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ export function useAdminNotificationPlatform() {
|
|||||||
function hydrateScheduledJobsConfig(data: AdminScheduledJobsResponse) {
|
function hydrateScheduledJobsConfig(data: AdminScheduledJobsResponse) {
|
||||||
scheduledJobsFilePath.value = data.filePath
|
scheduledJobsFilePath.value = data.filePath
|
||||||
scheduledJobsForm.value.enabled = data.source.enabled !== false
|
scheduledJobsForm.value.enabled = data.source.enabled !== false
|
||||||
|
cloudtentaclesMonitorAccounts.value = data.cloudtentaclesAccounts || []
|
||||||
scheduledJobs.value = data.source.jobs.map((item) => ({
|
scheduledJobs.value = data.source.jobs.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
@@ -119,7 +122,12 @@ export function useAdminNotificationPlatform() {
|
|||||||
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
||||||
cooldownSecondsAmount: resolveTimeValue(Number(item.cooldownSeconds || 1800)).amount,
|
cooldownSecondsAmount: resolveTimeValue(Number(item.cooldownSeconds || 1800)).amount,
|
||||||
cooldownSecondsUnit: resolveTimeValue(Number(item.cooldownSeconds || 1800)).unit,
|
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 || []
|
scheduledJobRuntime.value = data.runtime || []
|
||||||
}
|
}
|
||||||
@@ -191,7 +199,13 @@ export function useAdminNotificationPlatform() {
|
|||||||
intervalSeconds: Number(item.intervalSeconds || 300),
|
intervalSeconds: Number(item.intervalSeconds || 300),
|
||||||
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
||||||
config: {
|
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
|
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) {
|
function resolveTimeValue(seconds: number) {
|
||||||
const normalized = Math.max(0, Number(seconds || 0))
|
const normalized = Math.max(0, Number(seconds || 0))
|
||||||
if (normalized >= 3600 && normalized % 3600 === 0) {
|
if (normalized >= 3600 && normalized % 3600 === 0) {
|
||||||
@@ -310,6 +367,7 @@ export function useAdminNotificationPlatform() {
|
|||||||
scheduledJobsForm,
|
scheduledJobsForm,
|
||||||
scheduledJobs,
|
scheduledJobs,
|
||||||
scheduledJobRuntime,
|
scheduledJobRuntime,
|
||||||
|
cloudtentaclesMonitorAccounts,
|
||||||
scheduledJobsSaving,
|
scheduledJobsSaving,
|
||||||
scheduledJobRunningId,
|
scheduledJobRunningId,
|
||||||
notificationStats,
|
notificationStats,
|
||||||
|
|||||||
Reference in New Issue
Block a user