优化监控任务多账号配置

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
+15 -1
View File
@@ -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
}>
} }
} }
+3
View File
@@ -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"
@@ -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>
@@ -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
} }
@@ -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,