release: merge latest development with script monitoring
This commit is contained in:
commit
422c23d3e5
3
furniture-3d-system/.gitignore
vendored
3
furniture-3d-system/.gitignore
vendored
@ -71,3 +71,6 @@ typecheck-latest.log
|
||||
.local-test-*.log
|
||||
tsconfig.typecheck.generated.json
|
||||
local-packages/npm-cache/cache/_logs/
|
||||
|
||||
# 脚本监控 Agent 本机凭据
|
||||
agents/script-monitor/config.json
|
||||
|
||||
25
furniture-3d-system/agents/script-monitor/README.md
Normal file
25
furniture-3d-system/agents/script-monitor/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 供应链脚本监控 Agent
|
||||
|
||||
每台运行脚本的机器部署一份 Agent。Agent 只主动向产品平台发起 HTTP 请求,不需要为被监控机器开放入站端口。
|
||||
|
||||
## 接入
|
||||
|
||||
1. 在供应链中心的“脚本监控”页面点击“接入机器”,生成独立的 `agentId` 和 `agentToken`。
|
||||
2. 复制本目录到目标机器,把 `config.example.json` 复制为 `config.json`,填入凭据、内外网地址和脚本任务说明。
|
||||
3. 先运行 `node agent.mjs` 确认上报,再使用 `pm2 start ecosystem.config.cjs` 常驻。
|
||||
|
||||
`connectionMode` 支持:
|
||||
|
||||
- `intranet`:只走内网地址。
|
||||
- `internet`:只走公网地址,生产必须使用 HTTPS。
|
||||
- `auto`:优先内网,失败后自动尝试公网。
|
||||
|
||||
## 记录真实运行结果
|
||||
|
||||
对于需要记录开始时间、耗时、退出码和输出的任务,用包装器代替原命令:
|
||||
|
||||
```bash
|
||||
node /opt/furniture-script-monitor/run-script.mjs supply-chain-archive-sync
|
||||
```
|
||||
|
||||
包装器执行 `config.json` 中同一 `key` 的 `command`,并在开始与结束时上报。Agent Token 只保存在目标机器的 `config.json`,该文件已被仓库忽略。
|
||||
287
furniture-3d-system/agents/script-monitor/agent.mjs
Normal file
287
furniture-3d-system/agents/script-monitor/agent.mjs
Normal file
@ -0,0 +1,287 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { hostname, platform, release } from 'node:os'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
export const AGENT_VERSION = '1.0.0'
|
||||
const AGENT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function requiredText(value, label) {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new Error(`缺少 ${label}`)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
export function loadConfig(configPath = process.env.SCRIPT_MONITOR_CONFIG || resolve(AGENT_DIR, 'config.json')) {
|
||||
if (!existsSync(configPath)) {
|
||||
throw new Error(`配置文件不存在:${configPath},请复制 config.example.json 为 config.json`)
|
||||
}
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8'))
|
||||
config.agentId = requiredText(config.agentId, 'agentId')
|
||||
config.agentToken = requiredText(config.agentToken, 'agentToken')
|
||||
config.connectionMode = String(config.connectionMode || 'auto').toLowerCase()
|
||||
if (!['intranet', 'internet', 'auto'].includes(config.connectionMode)) {
|
||||
throw new Error('connectionMode 必须是 intranet、internet 或 auto')
|
||||
}
|
||||
config.reportIntervalSec = Math.max(15, Number(config.reportIntervalSec) || 60)
|
||||
config.requestTimeoutMs = Math.max(1000, Number(config.requestTimeoutMs) || 10000)
|
||||
config.scripts = Array.isArray(config.scripts) ? config.scripts : []
|
||||
return config
|
||||
}
|
||||
|
||||
function normalizeCenterUrl(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
const url = new URL(value.trim())
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`不支持的中心地址协议:${url.protocol}`)
|
||||
return url.toString().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function resolveCenterEndpoints(config) {
|
||||
const intranet = normalizeCenterUrl(config.centerUrls?.intranet)
|
||||
const internet = normalizeCenterUrl(config.centerUrls?.internet)
|
||||
const endpoints = []
|
||||
if (config.connectionMode !== 'internet' && intranet) endpoints.push({ kind: 'INTRANET', url: intranet })
|
||||
if (config.connectionMode !== 'intranet' && internet) endpoints.push({ kind: 'INTERNET', url: internet })
|
||||
if (endpoints.length === 0) throw new Error('当前连接模式没有可用的 centerUrls 地址')
|
||||
return endpoints
|
||||
}
|
||||
|
||||
export async function postToCenter(config, route, payload) {
|
||||
let lastError = null
|
||||
for (const endpoint of resolveCenterEndpoints(config)) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs)
|
||||
try {
|
||||
const response = await fetch(`${endpoint.url}${route}`, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${config.agentToken}`,
|
||||
'x-script-monitor-agent-id': config.agentId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
...(route.endsWith('/report')
|
||||
? { connectionKind: payload.connectionKind ?? endpoint.kind }
|
||||
: {}),
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => '')
|
||||
throw new Error(`HTTP ${response.status}${message ? `:${message.slice(0, 240)}` : ''}`)
|
||||
}
|
||||
return { endpoint, body: await response.json().catch(() => null) }
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('没有可用的中心地址')
|
||||
}
|
||||
|
||||
function hashKey(value) {
|
||||
return createHash('sha256').update(value).digest('hex').slice(0, 20)
|
||||
}
|
||||
|
||||
function redactCommand(command) {
|
||||
return String(command || '')
|
||||
.replace(/([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY)[A-Z0-9_]*=)([^\s]+)/gi, '$1[REDACTED]')
|
||||
.replace(/(https?:\/\/[^:/\s]+:)[^@/\s]+@/gi, '$1[REDACTED]@')
|
||||
.slice(0, 2000)
|
||||
}
|
||||
|
||||
function readPm2() {
|
||||
try {
|
||||
const raw = execFileSync('pm2', ['jlist'], { encoding: 'utf8', timeout: 5000 })
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function scanPm2() {
|
||||
return readPm2().map((processInfo) => {
|
||||
const name = String(processInfo.name || '未命名 PM2 进程')
|
||||
const status = processInfo.pm2_env?.status === 'online'
|
||||
? 'RUNNING'
|
||||
: processInfo.pm2_env?.status === 'stopped'
|
||||
? 'DISABLED'
|
||||
: processInfo.pm2_env?.status === 'errored'
|
||||
? 'FAILED'
|
||||
: 'UNKNOWN'
|
||||
return {
|
||||
key: `pm2:${name}`,
|
||||
name,
|
||||
taskDescription: '',
|
||||
jobType: 'PM2',
|
||||
schedule: null,
|
||||
commandPreview: redactCommand(processInfo.pm2_env?.pm_exec_path || ''),
|
||||
expectedIntervalSec: null,
|
||||
status,
|
||||
isEnabled: status !== 'DISABLED',
|
||||
lastStartedAt: processInfo.pm2_env?.pm_uptime
|
||||
? new Date(processInfo.pm2_env.pm_uptime).toISOString()
|
||||
: null,
|
||||
lastResultSummary: status === 'RUNNING'
|
||||
? `PM2 在线,PID ${processInfo.pid || '-'}`
|
||||
: `PM2 状态:${processInfo.pm2_env?.status || 'unknown'}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseCronLine(line) {
|
||||
const value = line.trim()
|
||||
if (!value || value.startsWith('#')) return null
|
||||
const special = value.match(/^@(reboot|yearly|annually|monthly|weekly|daily|hourly)\s+(.+)$/)
|
||||
const regular = value.match(/^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/)
|
||||
const schedule = special ? `@${special[1]}` : regular?.[1]
|
||||
const command = special?.[2] ?? regular?.[2]
|
||||
if (!schedule || !command) return null
|
||||
const tag = command.match(/#\s*([\w.-]{3,})\s*$/)?.[1]
|
||||
const name = tag || command.split(/\s+/).filter(Boolean).slice(-1)[0] || 'cron task'
|
||||
return {
|
||||
key: `cron:${hashKey(`${schedule}\n${command}`)}`,
|
||||
name,
|
||||
taskDescription: '',
|
||||
jobType: 'CRON',
|
||||
schedule,
|
||||
commandPreview: redactCommand(command.replace(/\s+#.*$/, '')),
|
||||
expectedIntervalSec: null,
|
||||
status: 'UNKNOWN',
|
||||
isEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
function scanCron() {
|
||||
if (platform() === 'win32') return []
|
||||
try {
|
||||
const raw = execFileSync('crontab', ['-l'], { encoding: 'utf8', timeout: 5000 })
|
||||
return raw.split(/\r?\n/).map(parseCronLine).filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function checkProcess(name) {
|
||||
try {
|
||||
if (platform() === 'win32') {
|
||||
const output = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${name}`], { encoding: 'utf8', timeout: 4000 })
|
||||
return output.toLowerCase().includes(String(name).toLowerCase())
|
||||
}
|
||||
execFileSync('pgrep', ['-f', '--', name], { stdio: 'ignore', timeout: 4000 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkConfiguredScript(script) {
|
||||
if (script.enabled === false) return { status: 'DISABLED', summary: '监控已停用' }
|
||||
const check = script.resultCheck
|
||||
if (!check) return { status: 'UNKNOWN', summary: null }
|
||||
|
||||
if (check.type === 'file') {
|
||||
try {
|
||||
const stat = statSync(requiredText(check.path, 'resultCheck.path'))
|
||||
const ageSec = Math.max(0, Math.round((Date.now() - stat.mtimeMs) / 1000))
|
||||
const fresh = !check.maxAgeSec || ageSec <= Number(check.maxAgeSec)
|
||||
const largeEnough = check.minBytes == null || stat.size >= Number(check.minBytes)
|
||||
return {
|
||||
status: fresh && largeEnough ? 'SUCCESS' : 'FAILED',
|
||||
summary: `文件 ${stat.size} B,${ageSec} 秒前更新`,
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: 'FAILED', summary: `文件检查失败:${error.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
if (check.type === 'systemd') {
|
||||
try {
|
||||
const service = requiredText(check.service, 'resultCheck.service')
|
||||
const state = execFileSync('systemctl', ['is-active', service], { encoding: 'utf8', timeout: 4000 }).trim()
|
||||
return { status: state === 'active' ? 'RUNNING' : 'FAILED', summary: `systemd:${state}` }
|
||||
} catch {
|
||||
return { status: 'FAILED', summary: 'systemd 服务未运行' }
|
||||
}
|
||||
}
|
||||
|
||||
if (check.type === 'process') {
|
||||
const running = checkProcess(requiredText(check.name, 'resultCheck.name'))
|
||||
return { status: running ? 'RUNNING' : 'FAILED', summary: running ? '进程正在运行' : '未找到目标进程' }
|
||||
}
|
||||
|
||||
return { status: 'UNKNOWN', summary: '未识别的结果检查类型' }
|
||||
}
|
||||
|
||||
function normalizeConfiguredScript(script) {
|
||||
const key = requiredText(script.key, 'scripts[].key')
|
||||
const result = checkConfiguredScript(script)
|
||||
return {
|
||||
key,
|
||||
name: requiredText(script.name, `scripts[${key}].name`),
|
||||
taskDescription: String(script.taskDescription || '').slice(0, 2000),
|
||||
jobType: String(script.jobType || 'OTHER').toUpperCase(),
|
||||
schedule: script.schedule ? String(script.schedule).slice(0, 240) : null,
|
||||
commandPreview: redactCommand(script.commandPreview || script.command || ''),
|
||||
expectedIntervalSec: script.expectedIntervalSec ? Number(script.expectedIntervalSec) : null,
|
||||
status: result.status,
|
||||
isEnabled: script.enabled !== false,
|
||||
lastResultSummary: result.summary,
|
||||
}
|
||||
}
|
||||
|
||||
export function collectScripts(config) {
|
||||
const byKey = new Map()
|
||||
if (config.autoDiscover?.cron !== false) {
|
||||
for (const script of scanCron()) byKey.set(script.key, script)
|
||||
}
|
||||
if (config.autoDiscover?.pm2 !== false) {
|
||||
for (const script of scanPm2()) byKey.set(script.key, script)
|
||||
}
|
||||
for (const script of config.scripts) {
|
||||
const normalized = normalizeConfiguredScript(script)
|
||||
byKey.set(normalized.key, { ...(byKey.get(normalized.key) || {}), ...normalized })
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
|
||||
export async function reportSnapshot(config) {
|
||||
const scripts = collectScripts(config)
|
||||
const result = await postToCenter(config, '/api/internal/script-monitor/report', {
|
||||
hostname: hostname(),
|
||||
os: `${platform()} ${release()}`,
|
||||
agentVersion: AGENT_VERSION,
|
||||
reportIntervalSec: config.reportIntervalSec,
|
||||
scripts,
|
||||
})
|
||||
console.log(`[script-monitor] 已通过${result.endpoint.kind === 'INTRANET' ? '内网' : '公网'}上报 ${scripts.length} 个脚本`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig()
|
||||
const run = async () => {
|
||||
try {
|
||||
await reportSnapshot(config)
|
||||
} catch (error) {
|
||||
console.error('[script-monitor] 上报失败:', error.message)
|
||||
}
|
||||
}
|
||||
await run()
|
||||
const timer = setInterval(run, config.reportIntervalSec * 1000)
|
||||
const stop = () => {
|
||||
clearInterval(timer)
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', stop)
|
||||
process.on('SIGTERM', stop)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
main().catch((error) => {
|
||||
console.error('[script-monitor] 启动失败:', error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
{
|
||||
"agentId": "PASTE_AGENT_ID_HERE",
|
||||
"agentToken": "PASTE_AGENT_TOKEN_HERE",
|
||||
"agentName": "仓库服务器 01",
|
||||
"connectionMode": "auto",
|
||||
"centerUrls": {
|
||||
"intranet": "http://10.0.0.20:3001",
|
||||
"internet": "https://cp.jiansun.vip"
|
||||
},
|
||||
"reportIntervalSec": 60,
|
||||
"requestTimeoutMs": 10000,
|
||||
"autoDiscover": {
|
||||
"cron": true,
|
||||
"pm2": true
|
||||
},
|
||||
"scripts": [
|
||||
{
|
||||
"key": "supply-chain-archive-sync",
|
||||
"name": "供应链产品档案同步",
|
||||
"taskDescription": "同步聚水潭商品档案、仓库和供应商扩展字段",
|
||||
"jobType": "CRON",
|
||||
"schedule": "*/15 * * * *",
|
||||
"expectedIntervalSec": 900,
|
||||
"command": "npm run sync:supply-chain-product-archive",
|
||||
"cwd": "/opt/furniture-3d-system-prod/current",
|
||||
"resultCheck": {
|
||||
"type": "file",
|
||||
"path": "/opt/furniture-3d-system-prod/shared/logs/product-archive-sync.log",
|
||||
"maxAgeSec": 1800,
|
||||
"minBytes": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'furniture-script-monitor-agent',
|
||||
script: 'agent.mjs',
|
||||
cwd: __dirname,
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
SCRIPT_MONITOR_CONFIG: `${__dirname}/config.json`,
|
||||
},
|
||||
}],
|
||||
}
|
||||
82
furniture-3d-system/agents/script-monitor/run-script.mjs
Normal file
82
furniture-3d-system/agents/script-monitor/run-script.mjs
Normal file
@ -0,0 +1,82 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
import { loadConfig, postToCenter } from './agent.mjs'
|
||||
|
||||
function keepTail(current, chunk, limit = 12000) {
|
||||
const next = current + chunk.toString()
|
||||
return next.length > limit ? next.slice(-limit) : next
|
||||
}
|
||||
|
||||
async function sendRun(config, script, input) {
|
||||
return postToCenter(config, '/api/internal/script-monitor/run', {
|
||||
scriptKey: script.key,
|
||||
scriptName: script.name,
|
||||
taskDescription: script.taskDescription || '',
|
||||
jobType: String(script.jobType || 'OTHER').toUpperCase(),
|
||||
...input,
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptKey = process.argv[2]
|
||||
if (!scriptKey) throw new Error('用法:node run-script.mjs <script-key>')
|
||||
const config = loadConfig()
|
||||
const script = config.scripts.find((item) => item.key === scriptKey)
|
||||
if (!script) throw new Error(`配置中不存在脚本:${scriptKey}`)
|
||||
if (!script.command) throw new Error(`脚本 ${scriptKey} 未配置 command`)
|
||||
|
||||
const externalRunId = `${scriptKey}:${randomUUID()}`
|
||||
const startedAt = new Date()
|
||||
await sendRun(config, script, {
|
||||
externalRunId,
|
||||
status: 'RUNNING',
|
||||
trigger: 'wrapper',
|
||||
startedAt: startedAt.toISOString(),
|
||||
}).catch((error) => {
|
||||
console.warn('[script-monitor-run] 开始状态上报失败,不阻断原脚本:', error.message)
|
||||
})
|
||||
|
||||
let outputTail = ''
|
||||
let errorTail = ''
|
||||
const child = spawn(script.command, {
|
||||
cwd: script.cwd ? resolve(script.cwd) : process.cwd(),
|
||||
env: process.env,
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
child.stdout.on('data', (chunk) => {
|
||||
outputTail = keepTail(outputTail, chunk)
|
||||
process.stdout.write(chunk)
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
errorTail = keepTail(errorTail, chunk)
|
||||
process.stderr.write(chunk)
|
||||
})
|
||||
|
||||
const exitCode = await new Promise((resolveExit, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code) => resolveExit(code ?? 1))
|
||||
})
|
||||
const finishedAt = new Date()
|
||||
await sendRun(config, script, {
|
||||
externalRunId,
|
||||
status: exitCode === 0 ? 'SUCCESS' : 'FAILED',
|
||||
trigger: 'wrapper',
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
||||
exitCode,
|
||||
resultSummary: exitCode === 0 ? '脚本执行成功' : `脚本退出码 ${exitCode}`,
|
||||
outputTail,
|
||||
errorTail,
|
||||
}).catch((error) => {
|
||||
console.warn('[script-monitor-run] 运行结果上报失败,保留原退出码:', error.message)
|
||||
})
|
||||
process.exitCode = exitCode
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[script-monitor-run] 执行失败:', error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@ -0,0 +1,96 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorNetworkMode" AS ENUM ('INTRANET', 'INTERNET', 'AUTO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorConnectionKind" AS ENUM ('INTRANET', 'INTERNET');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorJobType" AS ENUM ('CRON', 'PM2', 'SYSTEMD', 'MANUAL', 'OTHER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorJobStatus" AS ENUM ('UNKNOWN', 'RUNNING', 'SUCCESS', 'FAILED', 'SILENT', 'DISABLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScriptMonitorRunStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScriptMonitorAgent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"hostname" TEXT,
|
||||
"networkMode" "ScriptMonitorNetworkMode" NOT NULL DEFAULT 'AUTO',
|
||||
"lastConnectionKind" "ScriptMonitorConnectionKind",
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenHint" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"os" TEXT,
|
||||
"agentVersion" TEXT,
|
||||
"reportIntervalSec" INTEGER NOT NULL DEFAULT 60,
|
||||
"lastSeenAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ScriptMonitorAgent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScriptMonitorScript" (
|
||||
"id" TEXT NOT NULL,
|
||||
"agentId" TEXT NOT NULL,
|
||||
"externalKey" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"taskDescription" TEXT NOT NULL DEFAULT '',
|
||||
"jobType" "ScriptMonitorJobType" NOT NULL,
|
||||
"schedule" TEXT,
|
||||
"commandPreview" TEXT,
|
||||
"expectedIntervalSec" INTEGER,
|
||||
"status" "ScriptMonitorJobStatus" NOT NULL DEFAULT 'UNKNOWN',
|
||||
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastStartedAt" TIMESTAMP(3),
|
||||
"lastFinishedAt" TIMESTAMP(3),
|
||||
"lastDurationMs" INTEGER,
|
||||
"lastExitCode" INTEGER,
|
||||
"lastResultSummary" TEXT,
|
||||
"lastOutputTail" TEXT,
|
||||
"lastErrorTail" TEXT,
|
||||
"lastReportedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ScriptMonitorScript_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScriptMonitorRun" (
|
||||
"id" TEXT NOT NULL,
|
||||
"agentId" TEXT NOT NULL,
|
||||
"scriptId" TEXT NOT NULL,
|
||||
"externalRunId" TEXT NOT NULL,
|
||||
"status" "ScriptMonitorRunStatus" NOT NULL,
|
||||
"trigger" TEXT,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL,
|
||||
"finishedAt" TIMESTAMP(3),
|
||||
"durationMs" INTEGER,
|
||||
"exitCode" INTEGER,
|
||||
"resultSummary" TEXT,
|
||||
"outputTail" TEXT,
|
||||
"errorTail" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ScriptMonitorRun_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ScriptMonitorAgent_tokenHash_key" ON "ScriptMonitorAgent"("tokenHash");
|
||||
CREATE INDEX "ScriptMonitorAgent_isActive_lastSeenAt_idx" ON "ScriptMonitorAgent"("isActive", "lastSeenAt");
|
||||
CREATE UNIQUE INDEX "ScriptMonitorScript_agentId_externalKey_key" ON "ScriptMonitorScript"("agentId", "externalKey");
|
||||
CREATE INDEX "ScriptMonitorScript_agentId_status_idx" ON "ScriptMonitorScript"("agentId", "status");
|
||||
CREATE INDEX "ScriptMonitorScript_status_lastReportedAt_idx" ON "ScriptMonitorScript"("status", "lastReportedAt");
|
||||
CREATE UNIQUE INDEX "ScriptMonitorRun_agentId_externalRunId_key" ON "ScriptMonitorRun"("agentId", "externalRunId");
|
||||
CREATE INDEX "ScriptMonitorRun_scriptId_startedAt_idx" ON "ScriptMonitorRun"("scriptId", "startedAt" DESC);
|
||||
CREATE INDEX "ScriptMonitorRun_agentId_startedAt_idx" ON "ScriptMonitorRun"("agentId", "startedAt" DESC);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScriptMonitorScript" ADD CONSTRAINT "ScriptMonitorScript_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "ScriptMonitorAgent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ScriptMonitorRun" ADD CONSTRAINT "ScriptMonitorRun_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "ScriptMonitorAgent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ScriptMonitorRun" ADD CONSTRAINT "ScriptMonitorRun_scriptId_fkey" FOREIGN KEY ("scriptId") REFERENCES "ScriptMonitorScript"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -3884,3 +3884,115 @@ model InstallationDemoTool {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 供应链脚本监控
|
||||
// ============================================
|
||||
|
||||
enum ScriptMonitorNetworkMode {
|
||||
INTRANET
|
||||
INTERNET
|
||||
AUTO
|
||||
}
|
||||
|
||||
enum ScriptMonitorConnectionKind {
|
||||
INTRANET
|
||||
INTERNET
|
||||
}
|
||||
|
||||
enum ScriptMonitorJobType {
|
||||
CRON
|
||||
PM2
|
||||
SYSTEMD
|
||||
MANUAL
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum ScriptMonitorJobStatus {
|
||||
UNKNOWN
|
||||
RUNNING
|
||||
SUCCESS
|
||||
FAILED
|
||||
SILENT
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum ScriptMonitorRunStatus {
|
||||
RUNNING
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
model ScriptMonitorAgent {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
hostname String?
|
||||
networkMode ScriptMonitorNetworkMode @default(AUTO)
|
||||
lastConnectionKind ScriptMonitorConnectionKind?
|
||||
tokenHash String @unique
|
||||
tokenHint String
|
||||
isActive Boolean @default(true)
|
||||
os String?
|
||||
agentVersion String?
|
||||
reportIntervalSec Int @default(60)
|
||||
lastSeenAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
scripts ScriptMonitorScript[]
|
||||
runs ScriptMonitorRun[]
|
||||
|
||||
@@index([isActive, lastSeenAt])
|
||||
}
|
||||
|
||||
model ScriptMonitorScript {
|
||||
id String @id @default(cuid())
|
||||
agentId String
|
||||
agent ScriptMonitorAgent @relation(fields: [agentId], references: [id], onDelete: Cascade)
|
||||
externalKey String
|
||||
name String
|
||||
taskDescription String @default("") @db.Text
|
||||
jobType ScriptMonitorJobType
|
||||
schedule String?
|
||||
commandPreview String? @db.Text
|
||||
expectedIntervalSec Int?
|
||||
status ScriptMonitorJobStatus @default(UNKNOWN)
|
||||
isEnabled Boolean @default(true)
|
||||
lastStartedAt DateTime?
|
||||
lastFinishedAt DateTime?
|
||||
lastDurationMs Int?
|
||||
lastExitCode Int?
|
||||
lastResultSummary String? @db.Text
|
||||
lastOutputTail String? @db.Text
|
||||
lastErrorTail String? @db.Text
|
||||
lastReportedAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
runs ScriptMonitorRun[]
|
||||
|
||||
@@unique([agentId, externalKey])
|
||||
@@index([agentId, status])
|
||||
@@index([status, lastReportedAt])
|
||||
}
|
||||
|
||||
model ScriptMonitorRun {
|
||||
id String @id @default(cuid())
|
||||
agentId String
|
||||
agent ScriptMonitorAgent @relation(fields: [agentId], references: [id], onDelete: Cascade)
|
||||
scriptId String
|
||||
script ScriptMonitorScript @relation(fields: [scriptId], references: [id], onDelete: Cascade)
|
||||
externalRunId String
|
||||
status ScriptMonitorRunStatus
|
||||
trigger String?
|
||||
startedAt DateTime
|
||||
finishedAt DateTime?
|
||||
durationMs Int?
|
||||
exitCode Int?
|
||||
resultSummary String? @db.Text
|
||||
outputTail String? @db.Text
|
||||
errorTail String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([agentId, externalRunId])
|
||||
@@index([scriptId, startedAt(sort: Desc)])
|
||||
@@index([agentId, startedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { scriptMonitorAgentReportSchema } from '@/server/dto/script-monitor.dto'
|
||||
import { scriptMonitorService } from '@/server/services/script-monitor.service'
|
||||
|
||||
function readAgentCredentials(request: NextRequest) {
|
||||
const agentId = request.headers.get('x-script-monitor-agent-id')?.trim() ?? ''
|
||||
const authorization = request.headers.get('authorization') ?? ''
|
||||
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : ''
|
||||
return { agentId, token }
|
||||
}
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const body = await request.json().catch(() => null)
|
||||
const parsed = scriptMonitorAgentReportSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'Agent 上报内容格式不正确',
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
const { agentId, token } = readAgentCredentials(request)
|
||||
return jsonSuccess(
|
||||
await scriptMonitorService.reportSnapshot(agentId, token, parsed.data),
|
||||
200,
|
||||
ctx.requestId,
|
||||
)
|
||||
},
|
||||
{ requireAuth: false },
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { scriptMonitorRunReportSchema } from '@/server/dto/script-monitor.dto'
|
||||
import { scriptMonitorService } from '@/server/services/script-monitor.service'
|
||||
|
||||
function readAgentCredentials(request: NextRequest) {
|
||||
const agentId = request.headers.get('x-script-monitor-agent-id')?.trim() ?? ''
|
||||
const authorization = request.headers.get('authorization') ?? ''
|
||||
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : ''
|
||||
return { agentId, token }
|
||||
}
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const body = await request.json().catch(() => null)
|
||||
const parsed = scriptMonitorRunReportSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'脚本运行结果格式不正确',
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
const { agentId, token } = readAgentCredentials(request)
|
||||
return jsonSuccess(
|
||||
await scriptMonitorService.reportRun(agentId, token, parsed.data),
|
||||
200,
|
||||
ctx.requestId,
|
||||
)
|
||||
},
|
||||
{ requireAuth: false },
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { createScriptMonitorAgentSchema } from '@/server/dto/script-monitor.dto'
|
||||
import { scriptMonitorService } from '@/server/services/script-monitor.service'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => jsonSuccess(await scriptMonitorService.getOverview(), 200, ctx.requestId),
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const body = await request.json().catch(() => null)
|
||||
const parsed = createScriptMonitorAgentSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'接入机器信息不正确',
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
return jsonSuccess(await scriptMonitorService.createAgent(parsed.data), 201, ctx.requestId)
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { scriptMonitorService } from '@/server/services/script-monitor.service'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
const limit = Number(request.nextUrl.searchParams.get('limit') ?? '30')
|
||||
return jsonSuccess(await scriptMonitorService.getRuns(id, limit), 200, ctx.requestId)
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
Activity,
|
||||
ClipboardList,
|
||||
LayoutDashboard,
|
||||
Database,
|
||||
@ -18,6 +19,7 @@ import {
|
||||
} from '../config/supply-chain-menu'
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Activity,
|
||||
LayoutDashboard,
|
||||
Database,
|
||||
PackagePlus,
|
||||
|
||||
@ -103,6 +103,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
|
||||
'/supply-chain/purchase/supplier-information': '供应商信息',
|
||||
'/supply-chain/purchase/daily-affairs': '日常事项',
|
||||
'/supply-chain/purchase/price-change': '产品变价',
|
||||
'/supply-chain/script-monitor': '脚本监控',
|
||||
'/supply-chain/operation-logs': '操作日志',
|
||||
'/supply-chain/user-permissions': '用户权限',
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ describe('supply chain menu', () => {
|
||||
'new-products',
|
||||
'purchase',
|
||||
'management',
|
||||
'script-monitor',
|
||||
'operation-logs',
|
||||
])
|
||||
})
|
||||
@ -69,6 +70,15 @@ describe('supply chain menu', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes script monitor as an independent supply-chain module', () => {
|
||||
expect(flattenSupplyChainMenu(filterSupplyChainMenu(['scriptMonitor']))).toEqual([
|
||||
expect.objectContaining({
|
||||
label: '脚本监控',
|
||||
route: '/supply-chain/script-monitor',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('exposes business management as a top-level route', () => {
|
||||
expect(flattenSupplyChainMenu()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({
|
||||
|
||||
@ -82,6 +82,13 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
|
||||
route: '/supply-chain/management',
|
||||
targetKey: 'businessManagement',
|
||||
},
|
||||
{
|
||||
key: 'script-monitor',
|
||||
label: '脚本监控',
|
||||
icon: 'Activity',
|
||||
route: '/supply-chain/script-monitor',
|
||||
targetKey: 'scriptMonitor',
|
||||
},
|
||||
{
|
||||
key: 'operation-logs',
|
||||
label: '操作日志',
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { ScriptMonitorOverview } from '@/lib/supply-chain-script-monitor'
|
||||
import { ScriptMonitorView } from './ScriptMonitorView'
|
||||
|
||||
const overview: ScriptMonitorOverview = {
|
||||
generatedAt: '2026-08-28T12:00:00.000Z',
|
||||
agents: [{
|
||||
id: 'agent-1',
|
||||
name: '仓库服务器 01',
|
||||
hostname: 'warehouse-01',
|
||||
networkMode: 'AUTO',
|
||||
connectionKind: 'INTRANET',
|
||||
online: true,
|
||||
isActive: true,
|
||||
os: 'linux 6.8',
|
||||
agentVersion: '1.0.0',
|
||||
reportIntervalSec: 60,
|
||||
lastSeenAt: '2026-08-28T11:59:30.000Z',
|
||||
scriptCount: 1,
|
||||
abnormalCount: 0,
|
||||
}],
|
||||
scripts: [{
|
||||
id: 'script-1',
|
||||
externalKey: 'archive-sync',
|
||||
name: '供应链产品档案同步',
|
||||
taskDescription: '同步商品、仓库和供应商扩展字段',
|
||||
jobType: 'CRON',
|
||||
schedule: '*/15 * * * *',
|
||||
commandPreview: 'npm run sync:supply-chain-product-archive',
|
||||
expectedIntervalSec: 900,
|
||||
status: 'SUCCESS',
|
||||
isEnabled: true,
|
||||
lastStartedAt: '2026-08-28T11:45:00.000Z',
|
||||
lastFinishedAt: '2026-08-28T11:45:12.000Z',
|
||||
lastDurationMs: 12_000,
|
||||
lastExitCode: 0,
|
||||
lastResultSummary: '同步 182 条档案',
|
||||
lastOutputTail: null,
|
||||
lastErrorTail: null,
|
||||
lastReportedAt: '2026-08-28T11:59:30.000Z',
|
||||
agent: {
|
||||
id: 'agent-1',
|
||||
name: '仓库服务器 01',
|
||||
hostname: 'warehouse-01',
|
||||
networkMode: 'AUTO',
|
||||
connectionKind: 'INTRANET',
|
||||
online: true,
|
||||
lastSeenAt: '2026-08-28T11:59:30.000Z',
|
||||
},
|
||||
latestRun: null,
|
||||
}],
|
||||
metrics: {
|
||||
agentTotal: 1,
|
||||
agentOnline: 1,
|
||||
scriptTotal: 1,
|
||||
healthy: 1,
|
||||
running: 0,
|
||||
abnormal: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
}
|
||||
|
||||
describe('ScriptMonitorView', () => {
|
||||
it('shows script purpose, machine, status and result in one table', () => {
|
||||
render(<ScriptMonitorView initialOverview={overview} canEdit databaseReady />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: '脚本监控' })).toBeInTheDocument()
|
||||
expect(screen.getByText('供应链产品档案同步')).toBeInTheDocument()
|
||||
expect(screen.getByText('同步商品、仓库和供应商扩展字段')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('仓库服务器 01')).toHaveLength(2)
|
||||
expect(screen.getByText('同步 182 条档案')).toBeInTheDocument()
|
||||
expect(screen.getByText('内网')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the empty state actionable for editors', () => {
|
||||
render(<ScriptMonitorView
|
||||
initialOverview={{
|
||||
...overview,
|
||||
agents: [],
|
||||
scripts: [],
|
||||
metrics: { agentTotal: 0, agentOnline: 0, scriptTotal: 0, healthy: 0, running: 0, abnormal: 0, unknown: 0 },
|
||||
}}
|
||||
canEdit
|
||||
databaseReady
|
||||
/>)
|
||||
|
||||
expect(screen.getByText('还没有接入机器')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /接入第一台机器/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,547 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
CircleCheck,
|
||||
Copy,
|
||||
Globe2,
|
||||
LoaderCircle,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
SquareTerminal,
|
||||
TriangleAlert,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type {
|
||||
ScriptMonitorAgentCredentials,
|
||||
ScriptMonitorJobStatus,
|
||||
ScriptMonitorLatestRun,
|
||||
ScriptMonitorNetworkMode,
|
||||
ScriptMonitorOverview,
|
||||
ScriptMonitorScriptRow,
|
||||
} from '@/lib/supply-chain-script-monitor'
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: { message?: string }
|
||||
}
|
||||
|
||||
const STATUS_META: Record<ScriptMonitorJobStatus, { label: string; className: string }> = {
|
||||
RUNNING: { label: '运行中', className: 'monitor-status-running' },
|
||||
SUCCESS: { label: '正常', className: 'monitor-status-success' },
|
||||
FAILED: { label: '失败', className: 'monitor-status-failed' },
|
||||
SILENT: { label: '超时未运行', className: 'monitor-status-silent' },
|
||||
DISABLED: { label: '已停用', className: 'monitor-status-disabled' },
|
||||
UNKNOWN: { label: '待确认', className: 'monitor-status-unknown' },
|
||||
}
|
||||
|
||||
const JOB_TYPE_LABELS = {
|
||||
CRON: 'Cron',
|
||||
PM2: 'PM2',
|
||||
SYSTEMD: 'systemd',
|
||||
MANUAL: '手动任务',
|
||||
OTHER: '其他',
|
||||
} as const
|
||||
|
||||
const NETWORK_MODE_LABELS: Record<ScriptMonitorNetworkMode, string> = {
|
||||
INTRANET: '仅内网',
|
||||
INTERNET: '仅公网',
|
||||
AUTO: '内网优先',
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return '尚未运行'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function formatDuration(value: number | null) {
|
||||
if (value == null) return '—'
|
||||
if (value < 1000) return `${value} ms`
|
||||
if (value < 60_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)} 秒`
|
||||
const minutes = Math.floor(value / 60_000)
|
||||
const seconds = Math.floor((value % 60_000) / 1000)
|
||||
return `${minutes} 分 ${seconds} 秒`
|
||||
}
|
||||
|
||||
function summarizeResult(script: ScriptMonitorScriptRow) {
|
||||
return script.latestRun?.resultSummary
|
||||
?? script.lastResultSummary
|
||||
?? script.latestRun?.errorTail?.split(/\r?\n/).filter(Boolean).at(-1)
|
||||
?? '暂无结果摘要'
|
||||
}
|
||||
|
||||
async function readApi<T>(response: Response): Promise<T> {
|
||||
const payload = await response.json().catch(() => null) as ApiEnvelope<T> | null
|
||||
if (!response.ok || !payload?.success || payload.data === undefined) {
|
||||
throw new Error(payload?.error?.message || `请求失败(HTTP ${response.status})`)
|
||||
}
|
||||
return payload.data
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ScriptMonitorJobStatus }) {
|
||||
const meta = STATUS_META[status]
|
||||
return (
|
||||
<span className={`monitor-status ${meta.className}`}>
|
||||
<span className="monitor-status-dot" aria-hidden="true" />
|
||||
{meta.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionBadge({ script }: { script: ScriptMonitorScriptRow }) {
|
||||
if (!script.agent.online) {
|
||||
return (
|
||||
<span className="monitor-connection monitor-connection-offline">
|
||||
<WifiOff size={12} aria-hidden="true" />离线
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const intranet = script.agent.connectionKind === 'INTRANET'
|
||||
return (
|
||||
<span className="monitor-connection">
|
||||
{intranet ? <Network size={12} aria-hidden="true" /> : <Globe2 size={12} aria-hidden="true" />}
|
||||
{intranet ? '内网' : '公网'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScriptMonitorView({
|
||||
initialOverview,
|
||||
canEdit,
|
||||
databaseReady,
|
||||
}: {
|
||||
initialOverview: ScriptMonitorOverview
|
||||
canEdit: boolean
|
||||
databaseReady: boolean
|
||||
}) {
|
||||
const [overview, setOverview] = useState(initialOverview)
|
||||
const [query, setQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'ALL' | ScriptMonitorJobStatus>('ALL')
|
||||
const [agentFilter, setAgentFilter] = useState('ALL')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [runs, setRuns] = useState<ScriptMonitorLatestRun[]>([])
|
||||
const [runsLoading, setRunsLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [connectOpen, setConnectOpen] = useState(false)
|
||||
|
||||
const refresh = useCallback(async (quiet = false) => {
|
||||
if (!databaseReady) return
|
||||
if (!quiet) setRefreshing(true)
|
||||
try {
|
||||
const next = await readApi<ScriptMonitorOverview>(
|
||||
await fetch('/api/supply-chain/script-monitor', { cache: 'no-store' }),
|
||||
)
|
||||
setOverview(next)
|
||||
setError(null)
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '脚本监控数据刷新失败')
|
||||
} finally {
|
||||
if (!quiet) setRefreshing(false)
|
||||
}
|
||||
}, [databaseReady])
|
||||
|
||||
useEffect(() => {
|
||||
if (!databaseReady) return
|
||||
const timer = window.setInterval(() => refresh(true), 30_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [databaseReady, refresh])
|
||||
|
||||
const selectedScript = useMemo(
|
||||
() => overview.scripts.find((script) => script.id === selectedId) ?? null,
|
||||
[overview.scripts, selectedId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedScript) {
|
||||
setRuns([])
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
setRunsLoading(true)
|
||||
fetch(`/api/supply-chain/script-monitor/scripts/${selectedScript.id}/runs?limit=20`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(readApi<ScriptMonitorLatestRun[]>)
|
||||
.then((records) => {
|
||||
if (active) setRuns(records)
|
||||
})
|
||||
.catch((nextError) => {
|
||||
if (active) setError(nextError instanceof Error ? nextError.message : '运行记录加载失败')
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setRunsLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [selectedScript])
|
||||
|
||||
const filteredScripts = useMemo(() => {
|
||||
const keyword = query.trim().toLocaleLowerCase('zh-CN')
|
||||
return overview.scripts.filter((script) => {
|
||||
if (statusFilter !== 'ALL' && script.status !== statusFilter) return false
|
||||
if (agentFilter !== 'ALL' && script.agent.id !== agentFilter) return false
|
||||
if (!keyword) return true
|
||||
return [
|
||||
script.name,
|
||||
script.taskDescription,
|
||||
script.agent.name,
|
||||
script.agent.hostname,
|
||||
script.schedule,
|
||||
script.commandPreview,
|
||||
].some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword))
|
||||
})
|
||||
}, [agentFilter, overview.scripts, query, statusFilter])
|
||||
|
||||
return (
|
||||
<div className="monitor-page">
|
||||
<header className="monitor-header">
|
||||
<div>
|
||||
<h1>脚本监控</h1>
|
||||
<p>集中查看不同机器上的任务、运行状态与最近结果</p>
|
||||
</div>
|
||||
<div className="monitor-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="monitor-button"
|
||||
onClick={() => refresh(false)}
|
||||
disabled={refreshing || !databaseReady}
|
||||
>
|
||||
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} aria-hidden="true" />
|
||||
{refreshing ? '刷新中' : '刷新'}
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="monitor-button monitor-button-primary"
|
||||
onClick={() => setConnectOpen(true)}
|
||||
disabled={!databaseReady}
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />接入机器
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="monitor-metrics" aria-label="脚本监控摘要">
|
||||
<div><Server size={16} /><span>机器</span><strong>{overview.metrics.agentOnline}/{overview.metrics.agentTotal}</strong><small>在线</small></div>
|
||||
<div><Activity size={16} /><span>脚本</span><strong>{overview.metrics.scriptTotal}</strong><small>已汇总</small></div>
|
||||
<div><CircleCheck size={16} /><span>正常</span><strong>{overview.metrics.healthy + overview.metrics.running}</strong><small>成功或运行中</small></div>
|
||||
<div className={overview.metrics.abnormal > 0 ? 'is-alert' : ''}><TriangleAlert size={16} /><span>异常</span><strong>{overview.metrics.abnormal}</strong><small>失败或超时</small></div>
|
||||
</section>
|
||||
|
||||
{!databaseReady ? (
|
||||
<div className="monitor-alert" role="alert">
|
||||
<TriangleAlert size={17} aria-hidden="true" />
|
||||
<div><strong>脚本监控数据表尚未部署</strong><span>需要先执行 migration `20260828143000_add_supply_chain_script_monitor`,当前未对数据库做任何写入。</span></div>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="monitor-alert" role="alert">
|
||||
<TriangleAlert size={17} aria-hidden="true" />
|
||||
<div><strong>数据加载失败</strong><span>{error}</span></div>
|
||||
<button type="button" onClick={() => setError(null)} aria-label="关闭提示"><X size={15} /></button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="monitor-toolbar">
|
||||
<label className="monitor-search">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<span className="sr-only">搜索脚本</span>
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索脚本、任务或机器" />
|
||||
</label>
|
||||
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)} aria-label="按状态筛选">
|
||||
<option value="ALL">全部状态</option>
|
||||
{Object.entries(STATUS_META).map(([value, meta]) => <option key={value} value={value}>{meta.label}</option>)}
|
||||
</select>
|
||||
<select value={agentFilter} onChange={(event) => setAgentFilter(event.target.value)} aria-label="按机器筛选">
|
||||
<option value="ALL">全部机器</option>
|
||||
{overview.agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
|
||||
</select>
|
||||
<span className="monitor-result-count">{filteredScripts.length} 条</span>
|
||||
</div>
|
||||
|
||||
<main className={`monitor-workspace ${selectedScript ? 'has-inspector' : ''}`}>
|
||||
<div className="monitor-table-wrap">
|
||||
{overview.agents.length === 0 ? (
|
||||
<section className="monitor-empty">
|
||||
<Wifi size={32} strokeWidth={1.4} aria-hidden="true" />
|
||||
<h2>还没有接入机器</h2>
|
||||
<p>为运行脚本的电脑生成独立 Agent 凭据,首次上报后会自动出现脚本清单。</p>
|
||||
{canEdit && databaseReady ? <button type="button" className="monitor-button monitor-button-primary" onClick={() => setConnectOpen(true)}><Plus size={15} />接入第一台机器</button> : null}
|
||||
</section>
|
||||
) : filteredScripts.length === 0 ? (
|
||||
<section className="monitor-empty">
|
||||
<SquareTerminal size={32} strokeWidth={1.4} aria-hidden="true" />
|
||||
<h2>{overview.scripts.length === 0 ? '等待 Agent 上报脚本' : '没有符合条件的脚本'}</h2>
|
||||
<p>{overview.scripts.length === 0 ? 'Agent 在线后会定期扫描并上报 Cron、PM2 及配置任务。' : '调整搜索词、状态或机器筛选后重试。'}</p>
|
||||
</section>
|
||||
) : (
|
||||
<table className="monitor-table">
|
||||
<thead><tr><th>状态</th><th>脚本与任务</th><th>运行机器</th><th className="monitor-col-secondary">类型 / 计划</th><th className="monitor-col-secondary">最近运行</th><th>脚本结果</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredScripts.map((script) => (
|
||||
<tr
|
||||
key={script.id}
|
||||
className={selectedId === script.id ? 'is-selected' : ''}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`查看 ${script.name} 详情`}
|
||||
onClick={() => setSelectedId(script.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setSelectedId(script.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td><StatusBadge status={script.status} /></td>
|
||||
<td><strong>{script.name}</strong><span>{script.taskDescription || '暂未填写任务说明'}</span></td>
|
||||
<td><strong>{script.agent.name}</strong><span className="monitor-agent-line"><ConnectionBadge script={script} />{script.agent.hostname || '未上报主机名'}</span></td>
|
||||
<td className="monitor-col-secondary"><strong>{JOB_TYPE_LABELS[script.jobType]}</strong><span>{script.schedule || '持续运行 / 无计划'}</span></td>
|
||||
<td className="monitor-col-secondary"><strong>{formatDateTime(script.lastFinishedAt ?? script.lastStartedAt)}</strong><span>{formatDuration(script.lastDurationMs)}</span></td>
|
||||
<td><strong className="monitor-result-summary">{summarizeResult(script)}</strong><span>{script.lastExitCode == null ? '无退出码' : `退出码 ${script.lastExitCode}`}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedScript ? (
|
||||
<ScriptInspector
|
||||
script={selectedScript}
|
||||
runs={runs}
|
||||
loading={runsLoading}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
{connectOpen ? (
|
||||
<ConnectAgentDialog
|
||||
onClose={() => setConnectOpen(false)}
|
||||
onCreated={() => refresh(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<style jsx>{`
|
||||
.monitor-page { display:flex; min-height:0; flex:1; flex-direction:column; color:var(--sc-ink); background:var(--sc-bg); }
|
||||
.monitor-header { display:flex; align-items:flex-end; justify-content:space-between; gap:20px; padding:20px 24px 16px; border-bottom:1px solid var(--sc-border); background:var(--sc-surface); }
|
||||
.monitor-header h1 { font-size:20px; line-height:1.3; font-weight:700; letter-spacing:-0.02em; }
|
||||
.monitor-header p { margin-top:5px; font-size:12px; color:var(--sc-muted); }
|
||||
.monitor-header-actions { display:flex; gap:8px; }
|
||||
.monitor-button { display:inline-flex; min-height:36px; align-items:center; justify-content:center; gap:7px; border:1px solid var(--sc-border); border-radius:8px; padding:0 12px; background:var(--sc-surface); color:var(--sc-ink); font-size:12px; font-weight:600; transition:background .15s,border-color .15s; }
|
||||
.monitor-button:hover:not(:disabled) { background:var(--sc-hover); }
|
||||
.monitor-button:focus-visible, .monitor-toolbar select:focus-visible, .monitor-search:focus-within { outline:2px solid #7c8588; outline-offset:2px; }
|
||||
.monitor-button:disabled { cursor:not-allowed; opacity:.45; }
|
||||
.monitor-button-primary { border-color:#2f3436; background:#2f3436; color:#fff; }
|
||||
.monitor-button-primary:hover:not(:disabled) { background:#202426; }
|
||||
.monitor-metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); border-bottom:1px solid var(--sc-border); background:var(--sc-surface); }
|
||||
.monitor-metrics > div { display:grid; grid-template-columns:auto 1fr auto; align-items:center; column-gap:8px; min-height:68px; padding:10px 20px; border-right:1px solid var(--sc-border); }
|
||||
.monitor-metrics > div:last-child { border-right:0; }
|
||||
.monitor-metrics svg { color:var(--sc-muted); }
|
||||
.monitor-metrics span { font-size:11px; color:var(--sc-muted); }
|
||||
.monitor-metrics strong { grid-row:1 / span 2; grid-column:3; font-variant-numeric:tabular-nums; font-size:24px; line-height:1; letter-spacing:-.03em; }
|
||||
.monitor-metrics small { grid-column:2; font-size:10px; color:var(--sc-subtle); }
|
||||
.monitor-metrics .is-alert strong, .monitor-metrics .is-alert svg { color:#9f3a38; }
|
||||
.monitor-alert { display:flex; align-items:flex-start; gap:10px; margin:12px 24px 0; border:1px solid #d9c4ba; border-radius:9px; padding:10px 12px; background:#fffaf7; color:#69443a; font-size:12px; }
|
||||
.monitor-alert div { display:flex; flex:1; flex-direction:column; gap:2px; }
|
||||
.monitor-alert span { color:#7b625a; }
|
||||
.monitor-alert button { padding:3px; }
|
||||
.monitor-toolbar { display:flex; align-items:center; gap:8px; padding:12px 24px; border-bottom:1px solid var(--sc-border); background:var(--sc-surface); }
|
||||
.monitor-search { display:flex; width:min(360px,40vw); min-height:36px; align-items:center; gap:8px; border:1px solid var(--sc-border); border-radius:8px; padding:0 10px; background:var(--sc-surface); color:var(--sc-muted); }
|
||||
.monitor-search input { min-width:0; flex:1; border:0; outline:0; background:transparent; color:var(--sc-ink); font-size:12px; }
|
||||
.monitor-search input::placeholder { color:#777f82; }
|
||||
.monitor-toolbar select { min-height:36px; border:1px solid var(--sc-border); border-radius:8px; padding:0 28px 0 10px; background:var(--sc-surface); color:var(--sc-ink); font-size:12px; }
|
||||
.monitor-result-count { margin-left:auto; color:var(--sc-muted); font-size:11px; font-variant-numeric:tabular-nums; }
|
||||
.monitor-workspace { position:relative; display:grid; min-height:0; flex:1; grid-template-columns:minmax(0,1fr); overflow:hidden; }
|
||||
.monitor-workspace.has-inspector { grid-template-columns:minmax(0,1fr) 370px; }
|
||||
.monitor-table-wrap { min-width:0; overflow:auto; background:var(--sc-surface); scrollbar-color:#c6cbcd transparent; scrollbar-width:thin; }
|
||||
.monitor-table { width:100%; min-width:980px; border-collapse:separate; border-spacing:0; table-layout:fixed; font-size:12px; }
|
||||
.monitor-table th { position:sticky; top:0; z-index:2; height:38px; border-bottom:1px solid var(--sc-border); padding:0 14px; background:var(--sc-surface-muted); color:var(--sc-muted); text-align:left; font-size:10px; font-weight:600; letter-spacing:.03em; }
|
||||
.monitor-table th:nth-child(1) { width:110px; }.monitor-table th:nth-child(2) { width:25%; }.monitor-table th:nth-child(3) { width:17%; }.monitor-table th:nth-child(4) { width:14%; }.monitor-table th:nth-child(5) { width:16%; }
|
||||
.monitor-table td { height:66px; border-bottom:1px solid var(--sc-border); padding:9px 14px; vertical-align:middle; }
|
||||
.monitor-table tbody tr { cursor:pointer; outline:0; transition:background .12s; }
|
||||
.monitor-table tbody tr:hover, .monitor-table tbody tr:focus-visible { background:var(--sc-hover); }
|
||||
.monitor-table tbody tr.is-selected { background:var(--sc-surface-strong); }
|
||||
.monitor-table td strong { display:block; overflow:hidden; color:var(--sc-ink); font-weight:600; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.monitor-table td span:not(.monitor-status):not(.monitor-connection) { display:block; margin-top:4px; overflow:hidden; color:var(--sc-muted); font-size:10px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.monitor-result-summary { max-width:100%; }
|
||||
.monitor-agent-line { display:flex!important; align-items:center; gap:6px; }
|
||||
:global(.monitor-status) { display:inline-flex; align-items:center; gap:6px; border-radius:999px; padding:4px 7px; background:#f4f5f5; color:#535b5e; font-size:10px; font-weight:600; white-space:nowrap; }
|
||||
:global(.monitor-status-dot) { width:6px; height:6px; border-radius:50%; background:currentColor; }
|
||||
:global(.monitor-status-success) { background:#edf4f0; color:#35624c; }
|
||||
:global(.monitor-status-running) { background:#eef3f5; color:#315868; }
|
||||
:global(.monitor-status-failed) { background:#faeeee; color:#9f3a38; }
|
||||
:global(.monitor-status-silent) { background:#f7f1e7; color:#7b5b24; }
|
||||
:global(.monitor-status-disabled), :global(.monitor-status-unknown) { background:#f2f3f3; color:#656d70; }
|
||||
:global(.monitor-connection) { display:inline-flex; align-items:center; gap:3px; border:1px solid var(--sc-border); border-radius:999px; padding:1px 5px; color:var(--sc-muted); font-size:9px; }
|
||||
:global(.monitor-connection-offline) { color:#8e4845; }
|
||||
.monitor-empty { display:flex; min-height:360px; flex-direction:column; align-items:center; justify-content:center; padding:40px; color:var(--sc-muted); text-align:center; }
|
||||
.monitor-empty h2 { margin-top:14px; color:var(--sc-ink); font-size:15px; font-weight:650; }
|
||||
.monitor-empty p { max-width:460px; margin:7px 0 18px; font-size:12px; line-height:1.7; }
|
||||
@media (max-width: 1000px) { .monitor-workspace.has-inspector { grid-template-columns:minmax(0,1fr); } }
|
||||
@media (max-width: 760px) { .monitor-header { flex-direction:column; align-items:stretch; gap:12px; padding:16px; }.monitor-header p { max-width:none; }.monitor-header-actions { justify-content:flex-end; }.monitor-button { padding:0 10px; }.monitor-metrics { grid-template-columns:repeat(2,minmax(0,1fr)); }.monitor-metrics > div:nth-child(2) { border-right:0; }.monitor-metrics > div { min-height:58px; padding:8px 12px; }.monitor-toolbar { flex-wrap:wrap; padding:10px 16px; }.monitor-search { width:100%; }.monitor-toolbar select { flex:1; }.monitor-result-count { width:100%; margin-left:0; text-align:right; }.monitor-table { min-width:650px; }.monitor-col-secondary { display:none; } }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScriptInspector({
|
||||
script,
|
||||
runs,
|
||||
loading,
|
||||
onClose,
|
||||
}: {
|
||||
script: ScriptMonitorScriptRow
|
||||
runs: ScriptMonitorLatestRun[]
|
||||
loading: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const output = script.latestRun?.outputTail ?? script.lastOutputTail
|
||||
const errorOutput = script.latestRun?.errorTail ?? script.lastErrorTail
|
||||
return (
|
||||
<aside className="monitor-inspector" aria-label={`${script.name} 详情`}>
|
||||
<header><div><StatusBadge status={script.status} /><h2>{script.name}</h2></div><button type="button" onClick={onClose} aria-label="关闭脚本详情"><X size={17} /></button></header>
|
||||
<div className="monitor-inspector-scroll">
|
||||
<section><h3>任务</h3><p>{script.taskDescription || '暂未填写任务说明'}</p></section>
|
||||
<dl>
|
||||
<div><dt>运行机器</dt><dd>{script.agent.name}<small>{script.agent.hostname || '未上报主机名'}</small></dd></div>
|
||||
<div><dt>网络通道</dt><dd>{script.agent.online ? (script.agent.connectionKind === 'INTRANET' ? '内网连接' : '公网连接') : '机器离线'}<small>{script.agent.lastSeenAt ? `最后心跳 ${formatDateTime(script.agent.lastSeenAt)}` : '尚无心跳'}</small></dd></div>
|
||||
<div><dt>任务类型</dt><dd>{JOB_TYPE_LABELS[script.jobType]}<small>{script.schedule || '无固定计划'}</small></dd></div>
|
||||
<div><dt>最近运行</dt><dd>{formatDateTime(script.lastFinishedAt ?? script.lastStartedAt)}<small>{formatDuration(script.lastDurationMs)} · {script.lastExitCode == null ? '无退出码' : `退出码 ${script.lastExitCode}`}</small></dd></div>
|
||||
</dl>
|
||||
<section><h3>最近结果</h3><p>{summarizeResult(script)}</p></section>
|
||||
{script.commandPreview ? <section><h3>命令摘要</h3><pre>{script.commandPreview}</pre></section> : null}
|
||||
{output ? <section><h3>标准输出</h3><pre>{output}</pre></section> : null}
|
||||
{errorOutput ? <section className="is-error"><h3>错误输出</h3><pre>{errorOutput}</pre></section> : null}
|
||||
<section><h3>运行记录</h3>{loading ? <p className="monitor-loading"><LoaderCircle size={14} className="animate-spin" />加载中</p> : runs.length === 0 ? <p>暂无包装器上报的运行记录</p> : <ol className="monitor-run-list">{runs.map((run) => <li key={run.id}><span className={`monitor-run-mark is-${run.status.toLowerCase()}`} /><div><strong>{run.status === 'SUCCESS' ? '执行成功' : run.status === 'FAILED' ? '执行失败' : '正在运行'}</strong><small>{formatDateTime(run.startedAt)} · {formatDuration(run.durationMs)}</small></div><code>{run.exitCode ?? '—'}</code></li>)}</ol>}</section>
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.monitor-inspector { min-width:0; border-left:1px solid var(--sc-border); background:var(--sc-surface); }
|
||||
header { display:flex; min-height:72px; align-items:flex-start; justify-content:space-between; gap:12px; border-bottom:1px solid var(--sc-border); padding:14px 16px; }
|
||||
header h2 { margin-top:8px; font-size:15px; font-weight:700; letter-spacing:-.01em; }
|
||||
header button { border-radius:7px; padding:6px; color:var(--sc-muted); } header button:hover { background:var(--sc-hover); color:var(--sc-ink); }
|
||||
.monitor-inspector-scroll { height:calc(100% - 72px); overflow:auto; scrollbar-color:#c6cbcd transparent; scrollbar-width:thin; }
|
||||
section { border-bottom:1px solid var(--sc-border); padding:14px 16px; } section h3 { margin-bottom:7px; color:var(--sc-muted); font-size:10px; font-weight:650; letter-spacing:.04em; } section p { color:var(--sc-ink); font-size:12px; line-height:1.65; }
|
||||
dl { display:grid; grid-template-columns:1fr 1fr; border-bottom:1px solid var(--sc-border); } dl div { padding:12px 16px; border-right:1px solid var(--sc-border); border-bottom:1px solid var(--sc-border); } dl div:nth-child(2n) { border-right:0; } dl div:nth-last-child(-n+2) { border-bottom:0; } dt { margin-bottom:5px; color:var(--sc-muted); font-size:10px; } dd { font-size:12px; font-weight:600; } dd small { display:block; margin-top:3px; color:var(--sc-muted); font-size:9px; font-weight:400; line-height:1.45; }
|
||||
pre { max-height:180px; overflow:auto; border:1px solid var(--sc-border); border-radius:7px; padding:10px; background:var(--sc-surface-muted); color:#303638; font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:10px; line-height:1.55; white-space:pre-wrap; word-break:break-word; }
|
||||
.is-error pre { border-color:#e5cbc9; background:#fff8f7; color:#7f3735; }
|
||||
.monitor-loading { display:flex; align-items:center; gap:6px; color:var(--sc-muted); }
|
||||
.monitor-run-list { display:flex; flex-direction:column; gap:0; } .monitor-run-list li { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:9px; min-height:45px; border-bottom:1px solid var(--sc-border); } .monitor-run-list li:last-child { border-bottom:0; } .monitor-run-list strong { display:block; font-size:11px; font-weight:600; } .monitor-run-list small { display:block; margin-top:2px; color:var(--sc-muted); font-size:9px; } .monitor-run-list code { color:var(--sc-muted); font-size:10px; font-variant-numeric:tabular-nums; }
|
||||
.monitor-run-mark { width:7px; height:7px; border-radius:50%; background:#737b7e; }.monitor-run-mark.is-success { background:#47705a; }.monitor-run-mark.is-failed { background:#9f3a38; }.monitor-run-mark.is-running { background:#3e6677; }
|
||||
@media (max-width:1000px) { .monitor-inspector { position:fixed; inset:96px 0 0 76px; z-index:60; border-left:1px solid var(--sc-border); box-shadow:-12px 0 30px rgba(24,28,30,.12); } }
|
||||
@media (max-width:760px) { .monitor-inspector { inset:56px 0 0 0; } }
|
||||
`}</style>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [name, setName] = useState('')
|
||||
const [networkMode, setNetworkMode] = useState<ScriptMonitorNetworkMode>('AUTO')
|
||||
const [intranetUrl, setIntranetUrl] = useState('')
|
||||
const [internetUrl, setInternetUrl] = useState(() => typeof window === 'undefined' ? '' : window.location.origin)
|
||||
const [credentials, setCredentials] = useState<ScriptMonitorAgentCredentials | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const configText = credentials ? JSON.stringify({
|
||||
agentId: credentials.agentId,
|
||||
agentToken: credentials.token,
|
||||
agentName: name,
|
||||
connectionMode: networkMode.toLowerCase(),
|
||||
centerUrls: {
|
||||
intranet: intranetUrl || undefined,
|
||||
internet: internetUrl || undefined,
|
||||
},
|
||||
reportIntervalSec: 60,
|
||||
autoDiscover: { cron: true, pm2: true },
|
||||
scripts: [],
|
||||
}, null, 2) : ''
|
||||
|
||||
async function createAgent() {
|
||||
if (!name.trim()) {
|
||||
setError('请填写机器名称')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await readApi<{
|
||||
credentials: ScriptMonitorAgentCredentials
|
||||
}>(await fetch('/api/supply-chain/script-monitor', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), networkMode }),
|
||||
}))
|
||||
setCredentials(result.credentials)
|
||||
onCreated()
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '机器接入凭据生成失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyConfig() {
|
||||
await navigator.clipboard.writeText(configText)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1600)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="monitor-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose() }}>
|
||||
<section className="monitor-dialog" role="dialog" aria-modal="true" aria-labelledby="connect-agent-title">
|
||||
<header><div><h2 id="connect-agent-title">接入脚本机器</h2><p>{credentials ? '凭据只显示一次,请立即复制到目标机器。' : '为每台机器生成独立凭据,并指定它应使用的网络通道。'}</p></div><button type="button" onClick={onClose} aria-label="关闭"><X size={18} /></button></header>
|
||||
{credentials ? (
|
||||
<div className="monitor-dialog-body">
|
||||
<div className="monitor-secret-notice"><TriangleAlert size={16} /><span>关闭后无法再次查看当前 Token;如遗失需要重新生成。</span></div>
|
||||
<label><span>Agent 配置</span><textarea readOnly value={configText} rows={15} /></label>
|
||||
<button type="button" className="monitor-dialog-primary" onClick={copyConfig}>{copied ? <Check size={15} /> : <Copy size={15} />}{copied ? '已复制' : '复制完整配置'}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="monitor-dialog-body">
|
||||
<label><span>机器名称</span><input autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:仓库服务器 01" /></label>
|
||||
<label><span>连接模式</span><select value={networkMode} onChange={(event) => setNetworkMode(event.target.value as ScriptMonitorNetworkMode)}>{Object.entries(NETWORK_MODE_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<div className="monitor-dialog-grid">
|
||||
<label><span>内网地址</span><input value={intranetUrl} onChange={(event) => setIntranetUrl(event.target.value)} placeholder="http://10.0.0.20:3001" disabled={networkMode === 'INTERNET'} /></label>
|
||||
<label><span>公网地址</span><input value={internetUrl} onChange={(event) => setInternetUrl(event.target.value)} placeholder="https://cp.jiansun.vip" disabled={networkMode === 'INTRANET'} /></label>
|
||||
</div>
|
||||
{error ? <p className="monitor-dialog-error">{error}</p> : null}
|
||||
<footer><button type="button" onClick={onClose}>取消</button><button type="button" className="monitor-dialog-primary" onClick={createAgent} disabled={submitting}>{submitting ? <LoaderCircle size={15} className="animate-spin" /> : <Plus size={15} />}{submitting ? '生成中' : '生成 Agent 凭据'}</button></footer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<style jsx>{`
|
||||
.monitor-dialog-backdrop { position:fixed; inset:0; z-index:80; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(28,32,34,.28); }
|
||||
.monitor-dialog { width:min(620px,100%); max-height:calc(100dvh - 48px); overflow:auto; border:1px solid var(--sc-border); border-radius:14px; background:var(--sc-surface); box-shadow:0 18px 48px rgba(25,29,31,.18); }
|
||||
header { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; border-bottom:1px solid var(--sc-border); padding:18px 20px; } header h2 { font-size:16px; font-weight:700; } header p { margin-top:5px; color:var(--sc-muted); font-size:11px; } header button { border-radius:7px; padding:5px; color:var(--sc-muted); } header button:hover { background:var(--sc-hover); }
|
||||
.monitor-dialog-body { display:flex; flex-direction:column; gap:15px; padding:20px; } label { display:flex; flex-direction:column; gap:6px; } label span { color:var(--sc-muted); font-size:11px; font-weight:600; } input, select, textarea { width:100%; border:1px solid var(--sc-border); border-radius:8px; background:var(--sc-surface); color:var(--sc-ink); font-size:12px; outline:0; } input, select { min-height:38px; padding:0 10px; } textarea { padding:10px; font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:10px; line-height:1.5; resize:vertical; } input:focus, select:focus, textarea:focus { border-color:#7c8588; outline:2px solid rgba(124,133,136,.18); } input:disabled { background:var(--sc-surface-muted); color:var(--sc-subtle); }
|
||||
.monitor-dialog-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
footer { display:flex; justify-content:flex-end; gap:8px; padding-top:4px; } footer button, .monitor-dialog-primary { display:inline-flex; min-height:38px; align-items:center; justify-content:center; gap:7px; border:1px solid var(--sc-border); border-radius:8px; padding:0 13px; font-size:12px; font-weight:600; } .monitor-dialog-primary { border-color:#2f3436; background:#2f3436; color:#fff; } .monitor-dialog-primary:disabled { opacity:.55; }
|
||||
.monitor-dialog-error { color:#923e3b; font-size:11px; }
|
||||
.monitor-secret-notice { display:flex; align-items:flex-start; gap:8px; border:1px solid #d9c4ba; border-radius:8px; padding:9px 10px; background:#fffaf7; color:#69443a; font-size:11px; }
|
||||
@media (max-width:620px) { .monitor-dialog-backdrop { align-items:flex-end; padding:0; }.monitor-dialog { max-height:92dvh; border-radius:14px 14px 0 0; }.monitor-dialog-grid { grid-template-columns:1fr; } }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { redirect } from 'next/navigation'
|
||||
import type { ScriptMonitorOverview } from '@/lib/supply-chain-script-monitor'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { scriptMonitorService } from '@/server/services/script-monitor.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
import { ScriptMonitorView } from './ScriptMonitorView'
|
||||
|
||||
export const metadata = { title: '脚本监控 | 供应链中心' }
|
||||
|
||||
function emptyOverview(): ScriptMonitorOverview {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
agents: [],
|
||||
scripts: [],
|
||||
metrics: {
|
||||
agentTotal: 0,
|
||||
agentOnline: 0,
|
||||
scriptTotal: 0,
|
||||
healthy: 0,
|
||||
running: 0,
|
||||
abnormal: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
export default async function ScriptMonitorPage() {
|
||||
const { authUser } = await requireSession('/login')
|
||||
const access = await supplyChainPermissionSettingsService.getUserAccess(authUser, 'scriptMonitor')
|
||||
if (!access.view) redirect('/supply-chain')
|
||||
|
||||
let databaseReady = true
|
||||
let initialOverview = emptyOverview()
|
||||
try {
|
||||
initialOverview = await scriptMonitorService.getOverview()
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2021') {
|
||||
databaseReady = false
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScriptMonitorView
|
||||
initialOverview={initialOverview}
|
||||
canEdit={access.edit}
|
||||
databaseReady={databaseReady}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -115,12 +115,18 @@ describe('supply chain permission settings', () => {
|
||||
expect(
|
||||
getSupplyChainPermissionTargetForPath('/supply-chain/management'),
|
||||
).toBe('businessManagement')
|
||||
expect(
|
||||
getSupplyChainPermissionTargetForPath('/supply-chain/script-monitor'),
|
||||
).toBe('scriptMonitor')
|
||||
expect(
|
||||
getSupplyChainApiPermissionRequirement(
|
||||
'/api/supply-chain/supplier-information',
|
||||
'PUT',
|
||||
),
|
||||
).toEqual({ targetKey: 'purchaseSupplierInformation', action: 'edit' })
|
||||
expect(
|
||||
getSupplyChainApiPermissionRequirement('/api/supply-chain/script-monitor', 'GET'),
|
||||
).toEqual({ targetKey: 'scriptMonitor', action: 'view' })
|
||||
})
|
||||
|
||||
it('keeps newly registered pages closed until a role or account opens them', () => {
|
||||
|
||||
@ -32,6 +32,7 @@ export type SupplyChainPermissionTargetKey =
|
||||
| 'purchaseSupplierInformation'
|
||||
| 'purchaseDailyAffairs'
|
||||
| 'purchasePriceChange'
|
||||
| 'scriptMonitor'
|
||||
| 'operationLogs'
|
||||
|
||||
export type SupplyChainPermissionSubject = Omit<
|
||||
@ -85,6 +86,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
|
||||
{ key: 'purchaseSupplierInformation', label: '供应商信息', description: '采购供应商信息汇总' },
|
||||
{ key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' },
|
||||
{ key: 'purchasePriceChange', label: '产品变价', description: '采购价格变更协作' },
|
||||
{ key: 'scriptMonitor', label: '脚本监控', description: '跨机器脚本任务、状态和运行结果' },
|
||||
{ key: 'operationLogs', label: '操作日志', description: '供应链操作审计' },
|
||||
]
|
||||
|
||||
@ -108,6 +110,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial<
|
||||
purchaseSupplierInformation: '/supply-chain/purchase/supplier-information',
|
||||
purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs',
|
||||
purchasePriceChange: '/supply-chain/purchase/price-change',
|
||||
scriptMonitor: '/supply-chain/script-monitor',
|
||||
operationLogs: '/supply-chain/operation-logs',
|
||||
}
|
||||
|
||||
@ -321,6 +324,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) {
|
||||
if (pathname === '/supply-chain/purchase/supplier-information') return 'purchaseSupplierInformation' as const
|
||||
if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const
|
||||
if (pathname === '/supply-chain/purchase/price-change') return 'purchasePriceChange' as const
|
||||
if (pathname === '/supply-chain/script-monitor') return 'scriptMonitor' as const
|
||||
if (pathname === '/supply-chain/operation-logs') return 'operationLogs' as const
|
||||
return null
|
||||
}
|
||||
@ -353,5 +357,8 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
|
||||
if (pathname.startsWith('/api/supply-chain/supplier-information')) {
|
||||
return { targetKey: 'purchaseSupplierInformation' as const, action }
|
||||
}
|
||||
if (pathname.startsWith('/api/supply-chain/script-monitor')) {
|
||||
return { targetKey: 'scriptMonitor' as const, action }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import {
|
||||
isScriptMonitorAgentOnline,
|
||||
resolveScriptMonitorEffectiveStatus,
|
||||
} from './supply-chain-script-monitor'
|
||||
|
||||
describe('supply chain script monitor status', () => {
|
||||
it('treats an agent as offline after three report intervals with a three-minute floor', () => {
|
||||
const now = Date.parse('2026-08-28T12:00:00.000Z')
|
||||
|
||||
expect(isScriptMonitorAgentOnline({
|
||||
isActive: true,
|
||||
lastSeenAt: '2026-08-28T11:58:00.000Z',
|
||||
reportIntervalSec: 30,
|
||||
}, now)).toBe(true)
|
||||
expect(isScriptMonitorAgentOnline({
|
||||
isActive: true,
|
||||
lastSeenAt: '2026-08-28T11:56:59.000Z',
|
||||
reportIntervalSec: 30,
|
||||
}, now)).toBe(false)
|
||||
})
|
||||
|
||||
it('marks overdue scheduled scripts silent without changing disabled tasks', () => {
|
||||
const now = Date.parse('2026-08-28T12:00:00.000Z')
|
||||
expect(resolveScriptMonitorEffectiveStatus({
|
||||
status: 'SUCCESS',
|
||||
isEnabled: true,
|
||||
expectedIntervalSec: 900,
|
||||
lastFinishedAt: '2026-08-28T11:20:00.000Z',
|
||||
}, now)).toBe('SILENT')
|
||||
expect(resolveScriptMonitorEffectiveStatus({
|
||||
status: 'SUCCESS',
|
||||
isEnabled: false,
|
||||
expectedIntervalSec: 900,
|
||||
lastFinishedAt: '2026-08-28T11:20:00.000Z',
|
||||
}, now)).toBe('DISABLED')
|
||||
})
|
||||
})
|
||||
123
furniture-3d-system/src/lib/supply-chain-script-monitor.ts
Normal file
123
furniture-3d-system/src/lib/supply-chain-script-monitor.ts
Normal file
@ -0,0 +1,123 @@
|
||||
export const SCRIPT_MONITOR_NETWORK_MODES = ['INTRANET', 'INTERNET', 'AUTO'] as const
|
||||
export const SCRIPT_MONITOR_CONNECTION_KINDS = ['INTRANET', 'INTERNET'] as const
|
||||
export const SCRIPT_MONITOR_JOB_TYPES = ['CRON', 'PM2', 'SYSTEMD', 'MANUAL', 'OTHER'] as const
|
||||
export const SCRIPT_MONITOR_JOB_STATUSES = [
|
||||
'UNKNOWN',
|
||||
'RUNNING',
|
||||
'SUCCESS',
|
||||
'FAILED',
|
||||
'SILENT',
|
||||
'DISABLED',
|
||||
] as const
|
||||
export const SCRIPT_MONITOR_RUN_STATUSES = ['RUNNING', 'SUCCESS', 'FAILED'] as const
|
||||
|
||||
export type ScriptMonitorNetworkMode = typeof SCRIPT_MONITOR_NETWORK_MODES[number]
|
||||
export type ScriptMonitorConnectionKind = typeof SCRIPT_MONITOR_CONNECTION_KINDS[number]
|
||||
export type ScriptMonitorJobType = typeof SCRIPT_MONITOR_JOB_TYPES[number]
|
||||
export type ScriptMonitorJobStatus = typeof SCRIPT_MONITOR_JOB_STATUSES[number]
|
||||
export type ScriptMonitorRunStatus = typeof SCRIPT_MONITOR_RUN_STATUSES[number]
|
||||
|
||||
export type ScriptMonitorLatestRun = {
|
||||
id: string
|
||||
status: ScriptMonitorRunStatus
|
||||
trigger: string | null
|
||||
startedAt: string
|
||||
finishedAt: string | null
|
||||
durationMs: number | null
|
||||
exitCode: number | null
|
||||
resultSummary: string | null
|
||||
outputTail: string | null
|
||||
errorTail: string | null
|
||||
}
|
||||
export type ScriptMonitorScriptRow = {
|
||||
id: string
|
||||
externalKey: string
|
||||
name: string
|
||||
taskDescription: string
|
||||
jobType: ScriptMonitorJobType
|
||||
schedule: string | null
|
||||
commandPreview: string | null
|
||||
expectedIntervalSec: number | null
|
||||
status: ScriptMonitorJobStatus
|
||||
isEnabled: boolean
|
||||
lastStartedAt: string | null
|
||||
lastFinishedAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastExitCode: number | null
|
||||
lastResultSummary: string | null
|
||||
lastOutputTail: string | null
|
||||
lastErrorTail: string | null
|
||||
lastReportedAt: string
|
||||
agent: {
|
||||
id: string
|
||||
name: string
|
||||
hostname: string | null
|
||||
networkMode: ScriptMonitorNetworkMode
|
||||
connectionKind: ScriptMonitorConnectionKind | null
|
||||
online: boolean
|
||||
lastSeenAt: string | null
|
||||
}
|
||||
latestRun: ScriptMonitorLatestRun | null
|
||||
}
|
||||
|
||||
export type ScriptMonitorOverview = {
|
||||
generatedAt: string
|
||||
agents: Array<{
|
||||
id: string
|
||||
name: string
|
||||
hostname: string | null
|
||||
networkMode: ScriptMonitorNetworkMode
|
||||
connectionKind: ScriptMonitorConnectionKind | null
|
||||
online: boolean
|
||||
isActive: boolean
|
||||
os: string | null
|
||||
agentVersion: string | null
|
||||
reportIntervalSec: number
|
||||
lastSeenAt: string | null
|
||||
scriptCount: number
|
||||
abnormalCount: number
|
||||
}>
|
||||
scripts: ScriptMonitorScriptRow[]
|
||||
metrics: {
|
||||
agentTotal: number
|
||||
agentOnline: number
|
||||
scriptTotal: number
|
||||
healthy: number
|
||||
running: number
|
||||
abnormal: number
|
||||
unknown: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ScriptMonitorAgentCredentials = {
|
||||
agentId: string
|
||||
token: string
|
||||
tokenHint: string
|
||||
}
|
||||
|
||||
export function isScriptMonitorAgentOnline(input: {
|
||||
isActive: boolean
|
||||
lastSeenAt: Date | string | null
|
||||
reportIntervalSec: number
|
||||
}, now = Date.now()) {
|
||||
if (!input.isActive || !input.lastSeenAt) return false
|
||||
const timeoutMs = Math.max(180, input.reportIntervalSec * 3) * 1000
|
||||
return now - new Date(input.lastSeenAt).getTime() <= timeoutMs
|
||||
}
|
||||
|
||||
export function resolveScriptMonitorEffectiveStatus(input: {
|
||||
status: ScriptMonitorJobStatus
|
||||
isEnabled: boolean
|
||||
expectedIntervalSec: number | null
|
||||
lastFinishedAt: Date | string | null
|
||||
}, now = Date.now()): ScriptMonitorJobStatus {
|
||||
if (!input.isEnabled || input.status === 'DISABLED') return 'DISABLED'
|
||||
if (
|
||||
input.expectedIntervalSec &&
|
||||
input.lastFinishedAt &&
|
||||
now - new Date(input.lastFinishedAt).getTime() > input.expectedIntervalSec * 2 * 1000
|
||||
) {
|
||||
return 'SILENT'
|
||||
}
|
||||
return input.status
|
||||
}
|
||||
63
furniture-3d-system/src/server/dto/script-monitor.dto.ts
Normal file
63
furniture-3d-system/src/server/dto/script-monitor.dto.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
SCRIPT_MONITOR_CONNECTION_KINDS,
|
||||
SCRIPT_MONITOR_JOB_STATUSES,
|
||||
SCRIPT_MONITOR_JOB_TYPES,
|
||||
SCRIPT_MONITOR_NETWORK_MODES,
|
||||
SCRIPT_MONITOR_RUN_STATUSES,
|
||||
} from '@/lib/supply-chain-script-monitor'
|
||||
|
||||
const optionalText = (max: number) => z.string().trim().max(max).nullable().optional()
|
||||
const optionalDateTime = z.string().datetime({ offset: true }).nullable().optional()
|
||||
|
||||
export const createScriptMonitorAgentSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
networkMode: z.enum(SCRIPT_MONITOR_NETWORK_MODES).default('AUTO'),
|
||||
}).strict()
|
||||
|
||||
export const scriptMonitorAgentReportSchema = z.object({
|
||||
hostname: optionalText(160),
|
||||
os: optionalText(240),
|
||||
agentVersion: optionalText(40),
|
||||
connectionKind: z.enum(SCRIPT_MONITOR_CONNECTION_KINDS),
|
||||
reportIntervalSec: z.number().int().min(15).max(3600).default(60),
|
||||
scripts: z.array(z.object({
|
||||
key: z.string().trim().min(1).max(240),
|
||||
name: z.string().trim().min(1).max(180),
|
||||
taskDescription: z.string().trim().max(2000).default(''),
|
||||
jobType: z.enum(SCRIPT_MONITOR_JOB_TYPES),
|
||||
schedule: optionalText(240),
|
||||
commandPreview: optionalText(2000),
|
||||
expectedIntervalSec: z.number().int().min(15).max(31_536_000).nullable().optional(),
|
||||
status: z.enum(SCRIPT_MONITOR_JOB_STATUSES).default('UNKNOWN'),
|
||||
isEnabled: z.boolean().default(true),
|
||||
lastStartedAt: optionalDateTime,
|
||||
lastFinishedAt: optionalDateTime,
|
||||
lastDurationMs: z.number().int().min(0).max(2_147_483_647).nullable().optional(),
|
||||
lastExitCode: z.number().int().min(-2_147_483_648).max(2_147_483_647).nullable().optional(),
|
||||
lastResultSummary: optionalText(4000),
|
||||
lastOutputTail: optionalText(12_000),
|
||||
lastErrorTail: optionalText(12_000),
|
||||
}).strict()).max(1000),
|
||||
}).strict()
|
||||
|
||||
export const scriptMonitorRunReportSchema = z.object({
|
||||
scriptKey: z.string().trim().min(1).max(240),
|
||||
scriptName: z.string().trim().min(1).max(180),
|
||||
taskDescription: z.string().trim().max(2000).default(''),
|
||||
jobType: z.enum(SCRIPT_MONITOR_JOB_TYPES),
|
||||
externalRunId: z.string().trim().min(1).max(240),
|
||||
status: z.enum(SCRIPT_MONITOR_RUN_STATUSES),
|
||||
trigger: optionalText(80),
|
||||
startedAt: z.string().datetime({ offset: true }),
|
||||
finishedAt: optionalDateTime,
|
||||
durationMs: z.number().int().min(0).max(2_147_483_647).nullable().optional(),
|
||||
exitCode: z.number().int().min(-2_147_483_648).max(2_147_483_647).nullable().optional(),
|
||||
resultSummary: optionalText(4000),
|
||||
outputTail: optionalText(12_000),
|
||||
errorTail: optionalText(12_000),
|
||||
}).strict()
|
||||
|
||||
export type CreateScriptMonitorAgentInput = z.infer<typeof createScriptMonitorAgentSchema>
|
||||
export type ScriptMonitorAgentReportInput = z.infer<typeof scriptMonitorAgentReportSchema>
|
||||
export type ScriptMonitorRunReportInput = z.infer<typeof scriptMonitorRunReportSchema>
|
||||
@ -0,0 +1,331 @@
|
||||
import type {
|
||||
Prisma,
|
||||
ScriptMonitorConnectionKind,
|
||||
ScriptMonitorJobStatus,
|
||||
ScriptMonitorJobType,
|
||||
ScriptMonitorNetworkMode,
|
||||
ScriptMonitorRunStatus,
|
||||
} from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const overviewInclude = {
|
||||
scripts: {
|
||||
orderBy: [{ status: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
runs: {
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ScriptMonitorAgentInclude
|
||||
|
||||
export type ScriptMonitorAgentOverviewRecord = Prisma.ScriptMonitorAgentGetPayload<{
|
||||
include: typeof overviewInclude
|
||||
}>
|
||||
|
||||
export type ScriptMonitorSnapshotRecord = {
|
||||
key: string
|
||||
name: string
|
||||
taskDescription: string
|
||||
jobType: ScriptMonitorJobType
|
||||
schedule?: string | null
|
||||
commandPreview?: string | null
|
||||
expectedIntervalSec?: number | null
|
||||
status: ScriptMonitorJobStatus
|
||||
isEnabled: boolean
|
||||
lastStartedAt?: Date | null
|
||||
lastFinishedAt?: Date | null
|
||||
lastDurationMs?: number | null
|
||||
lastExitCode?: number | null
|
||||
lastResultSummary?: string | null
|
||||
lastOutputTail?: string | null
|
||||
lastErrorTail?: string | null
|
||||
}
|
||||
|
||||
export interface ScriptMonitorRepository {
|
||||
createAgent(input: {
|
||||
name: string
|
||||
networkMode: ScriptMonitorNetworkMode
|
||||
tokenHash: string
|
||||
tokenHint: string
|
||||
}): Promise<{ id: string; name: string; networkMode: ScriptMonitorNetworkMode }>
|
||||
findAgentAuthentication(id: string): Promise<{
|
||||
id: string
|
||||
tokenHash: string
|
||||
isActive: boolean
|
||||
} | null>
|
||||
applySnapshot(input: {
|
||||
agentId: string
|
||||
hostname?: string | null
|
||||
os?: string | null
|
||||
agentVersion?: string | null
|
||||
connectionKind: ScriptMonitorConnectionKind
|
||||
reportIntervalSec: number
|
||||
scripts: ScriptMonitorSnapshotRecord[]
|
||||
reportedAt: Date
|
||||
}): Promise<void>
|
||||
recordRun(input: {
|
||||
agentId: string
|
||||
scriptKey: string
|
||||
scriptName: string
|
||||
taskDescription: string
|
||||
jobType: ScriptMonitorJobType
|
||||
externalRunId: string
|
||||
status: ScriptMonitorRunStatus
|
||||
trigger?: string | null
|
||||
startedAt: Date
|
||||
finishedAt?: Date | null
|
||||
durationMs?: number | null
|
||||
exitCode?: number | null
|
||||
resultSummary?: string | null
|
||||
outputTail?: string | null
|
||||
errorTail?: string | null
|
||||
reportedAt: Date
|
||||
}): Promise<void>
|
||||
listOverview(): Promise<ScriptMonitorAgentOverviewRecord[]>
|
||||
listRuns(scriptId: string, take: number): Promise<Array<{
|
||||
id: string
|
||||
status: ScriptMonitorRunStatus
|
||||
trigger: string | null
|
||||
startedAt: Date
|
||||
finishedAt: Date | null
|
||||
durationMs: number | null
|
||||
exitCode: number | null
|
||||
resultSummary: string | null
|
||||
outputTail: string | null
|
||||
errorTail: string | null
|
||||
}>>
|
||||
}
|
||||
|
||||
export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
|
||||
createAgent(input: {
|
||||
name: string
|
||||
networkMode: ScriptMonitorNetworkMode
|
||||
tokenHash: string
|
||||
tokenHint: string
|
||||
}) {
|
||||
return prisma.scriptMonitorAgent.create({
|
||||
data: input,
|
||||
select: { id: true, name: true, networkMode: true },
|
||||
})
|
||||
}
|
||||
|
||||
findAgentAuthentication(id: string) {
|
||||
return prisma.scriptMonitorAgent.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, tokenHash: true, isActive: true },
|
||||
})
|
||||
}
|
||||
|
||||
async applySnapshot(input: {
|
||||
agentId: string
|
||||
hostname?: string | null
|
||||
os?: string | null
|
||||
agentVersion?: string | null
|
||||
connectionKind: ScriptMonitorConnectionKind
|
||||
reportIntervalSec: number
|
||||
scripts: ScriptMonitorSnapshotRecord[]
|
||||
reportedAt: Date
|
||||
}) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.scriptMonitorAgent.update({
|
||||
where: { id: input.agentId },
|
||||
data: {
|
||||
hostname: input.hostname ?? undefined,
|
||||
os: input.os ?? undefined,
|
||||
agentVersion: input.agentVersion ?? undefined,
|
||||
lastConnectionKind: input.connectionKind,
|
||||
reportIntervalSec: input.reportIntervalSec,
|
||||
lastSeenAt: input.reportedAt,
|
||||
},
|
||||
})
|
||||
|
||||
for (const script of input.scripts) {
|
||||
await tx.scriptMonitorScript.upsert({
|
||||
where: {
|
||||
agentId_externalKey: {
|
||||
agentId: input.agentId,
|
||||
externalKey: script.key,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
agentId: input.agentId,
|
||||
externalKey: script.key,
|
||||
name: script.name,
|
||||
taskDescription: script.taskDescription,
|
||||
jobType: script.jobType,
|
||||
schedule: script.schedule ?? null,
|
||||
commandPreview: script.commandPreview ?? null,
|
||||
expectedIntervalSec: script.expectedIntervalSec ?? null,
|
||||
status: script.status,
|
||||
isEnabled: script.isEnabled,
|
||||
lastStartedAt: script.lastStartedAt ?? null,
|
||||
lastFinishedAt: script.lastFinishedAt ?? null,
|
||||
lastDurationMs: script.lastDurationMs ?? null,
|
||||
lastExitCode: script.lastExitCode ?? null,
|
||||
lastResultSummary: script.lastResultSummary ?? null,
|
||||
lastOutputTail: script.lastOutputTail ?? null,
|
||||
lastErrorTail: script.lastErrorTail ?? null,
|
||||
lastReportedAt: input.reportedAt,
|
||||
},
|
||||
update: {
|
||||
name: script.name,
|
||||
taskDescription: script.taskDescription,
|
||||
jobType: script.jobType,
|
||||
schedule: script.schedule ?? null,
|
||||
commandPreview: script.commandPreview ?? null,
|
||||
expectedIntervalSec: script.expectedIntervalSec ?? null,
|
||||
status: script.status,
|
||||
isEnabled: script.isEnabled,
|
||||
lastStartedAt: script.lastStartedAt ?? undefined,
|
||||
lastFinishedAt: script.lastFinishedAt ?? undefined,
|
||||
lastDurationMs: script.lastDurationMs ?? undefined,
|
||||
lastExitCode: script.lastExitCode ?? undefined,
|
||||
lastResultSummary: script.lastResultSummary ?? undefined,
|
||||
lastOutputTail: script.lastOutputTail ?? undefined,
|
||||
lastErrorTail: script.lastErrorTail ?? undefined,
|
||||
lastReportedAt: input.reportedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await tx.scriptMonitorScript.updateMany({
|
||||
where: {
|
||||
agentId: input.agentId,
|
||||
...(input.scripts.length > 0
|
||||
? { externalKey: { notIn: input.scripts.map((script) => script.key) } }
|
||||
: {}),
|
||||
},
|
||||
data: { status: 'UNKNOWN' },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async recordRun(input: {
|
||||
agentId: string
|
||||
scriptKey: string
|
||||
scriptName: string
|
||||
taskDescription: string
|
||||
jobType: ScriptMonitorJobType
|
||||
externalRunId: string
|
||||
status: ScriptMonitorRunStatus
|
||||
trigger?: string | null
|
||||
startedAt: Date
|
||||
finishedAt?: Date | null
|
||||
durationMs?: number | null
|
||||
exitCode?: number | null
|
||||
resultSummary?: string | null
|
||||
outputTail?: string | null
|
||||
errorTail?: string | null
|
||||
reportedAt: Date
|
||||
}) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.scriptMonitorAgent.update({
|
||||
where: { id: input.agentId },
|
||||
data: { lastSeenAt: input.reportedAt },
|
||||
})
|
||||
const script = await tx.scriptMonitorScript.upsert({
|
||||
where: {
|
||||
agentId_externalKey: {
|
||||
agentId: input.agentId,
|
||||
externalKey: input.scriptKey,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
agentId: input.agentId,
|
||||
externalKey: input.scriptKey,
|
||||
name: input.scriptName,
|
||||
taskDescription: input.taskDescription,
|
||||
jobType: input.jobType,
|
||||
status: input.status,
|
||||
lastStartedAt: input.startedAt,
|
||||
lastFinishedAt: input.finishedAt ?? null,
|
||||
lastDurationMs: input.durationMs ?? null,
|
||||
lastExitCode: input.exitCode ?? null,
|
||||
lastResultSummary: input.resultSummary ?? null,
|
||||
lastOutputTail: input.outputTail ?? null,
|
||||
lastErrorTail: input.errorTail ?? null,
|
||||
lastReportedAt: input.reportedAt,
|
||||
},
|
||||
update: {
|
||||
name: input.scriptName,
|
||||
taskDescription: input.taskDescription,
|
||||
jobType: input.jobType,
|
||||
status: input.status,
|
||||
lastStartedAt: input.startedAt,
|
||||
lastFinishedAt: input.finishedAt ?? null,
|
||||
lastDurationMs: input.durationMs ?? null,
|
||||
lastExitCode: input.exitCode ?? null,
|
||||
lastResultSummary: input.resultSummary ?? null,
|
||||
lastOutputTail: input.outputTail ?? null,
|
||||
lastErrorTail: input.errorTail ?? null,
|
||||
lastReportedAt: input.reportedAt,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
await tx.scriptMonitorRun.upsert({
|
||||
where: {
|
||||
agentId_externalRunId: {
|
||||
agentId: input.agentId,
|
||||
externalRunId: input.externalRunId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
agentId: input.agentId,
|
||||
scriptId: script.id,
|
||||
externalRunId: input.externalRunId,
|
||||
status: input.status,
|
||||
trigger: input.trigger ?? null,
|
||||
startedAt: input.startedAt,
|
||||
finishedAt: input.finishedAt ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
exitCode: input.exitCode ?? null,
|
||||
resultSummary: input.resultSummary ?? null,
|
||||
outputTail: input.outputTail ?? null,
|
||||
errorTail: input.errorTail ?? null,
|
||||
},
|
||||
update: {
|
||||
status: input.status,
|
||||
trigger: input.trigger ?? null,
|
||||
finishedAt: input.finishedAt ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
exitCode: input.exitCode ?? null,
|
||||
resultSummary: input.resultSummary ?? null,
|
||||
outputTail: input.outputTail ?? null,
|
||||
errorTail: input.errorTail ?? null,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
listOverview() {
|
||||
return prisma.scriptMonitorAgent.findMany({
|
||||
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
||||
include: overviewInclude,
|
||||
})
|
||||
}
|
||||
|
||||
listRuns(scriptId: string, take: number) {
|
||||
return prisma.scriptMonitorRun.findMany({
|
||||
where: { scriptId },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
trigger: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
durationMs: true,
|
||||
exitCode: true,
|
||||
resultSummary: true,
|
||||
outputTail: true,
|
||||
errorTail: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const scriptMonitorRepository = new PrismaScriptMonitorRepository()
|
||||
@ -0,0 +1,93 @@
|
||||
import { createHash } from 'crypto'
|
||||
import type { ScriptMonitorRepository } from '@/server/repositories/script-monitor.repository'
|
||||
import { ScriptMonitorService } from './script-monitor.service'
|
||||
|
||||
function tokenHash(value: string) {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
function repositoryMock(): jest.Mocked<ScriptMonitorRepository> {
|
||||
return {
|
||||
createAgent: jest.fn(),
|
||||
findAgentAuthentication: jest.fn(),
|
||||
applySnapshot: jest.fn(),
|
||||
recordRun: jest.fn(),
|
||||
listOverview: jest.fn(),
|
||||
listRuns: jest.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ScriptMonitorService', () => {
|
||||
it('returns the raw token once while persisting only its hash', async () => {
|
||||
const repository = repositoryMock()
|
||||
repository.createAgent.mockResolvedValue({
|
||||
id: 'agent-1',
|
||||
name: '仓库服务器 01',
|
||||
networkMode: 'AUTO',
|
||||
})
|
||||
const service = new ScriptMonitorService(repository)
|
||||
|
||||
const result = await service.createAgent({ name: '仓库服务器 01', networkMode: 'AUTO' })
|
||||
|
||||
expect(result.credentials.agentId).toBe('agent-1')
|
||||
expect(result.credentials.token.length).toBeGreaterThan(30)
|
||||
const persisted = repository.createAgent.mock.calls[0][0]
|
||||
expect(persisted.tokenHash).toBe(tokenHash(result.credentials.token))
|
||||
expect(JSON.stringify(persisted)).not.toContain(result.credentials.token)
|
||||
})
|
||||
|
||||
it('authenticates an agent and maps its snapshot into repository records', async () => {
|
||||
const repository = repositoryMock()
|
||||
repository.findAgentAuthentication.mockResolvedValue({
|
||||
id: 'agent-1',
|
||||
isActive: true,
|
||||
tokenHash: tokenHash('agent-secret'),
|
||||
})
|
||||
const service = new ScriptMonitorService(repository)
|
||||
|
||||
await service.reportSnapshot('agent-1', 'agent-secret', {
|
||||
hostname: 'warehouse-01',
|
||||
os: 'linux 6.8',
|
||||
agentVersion: '1.0.0',
|
||||
connectionKind: 'INTRANET',
|
||||
reportIntervalSec: 60,
|
||||
scripts: [{
|
||||
key: 'archive-sync',
|
||||
name: '供应链产品档案同步',
|
||||
taskDescription: '同步供应链档案',
|
||||
jobType: 'CRON',
|
||||
schedule: '*/15 * * * *',
|
||||
expectedIntervalSec: 900,
|
||||
status: 'SUCCESS',
|
||||
isEnabled: true,
|
||||
lastFinishedAt: '2026-08-28T11:45:12.000Z',
|
||||
}],
|
||||
})
|
||||
|
||||
expect(repository.applySnapshot).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agentId: 'agent-1',
|
||||
connectionKind: 'INTRANET',
|
||||
scripts: [expect.objectContaining({
|
||||
key: 'archive-sync',
|
||||
lastFinishedAt: new Date('2026-08-28T11:45:12.000Z'),
|
||||
})],
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects a token belonging to another machine before writing data', async () => {
|
||||
const repository = repositoryMock()
|
||||
repository.findAgentAuthentication.mockResolvedValue({
|
||||
id: 'agent-1',
|
||||
isActive: true,
|
||||
tokenHash: tokenHash('correct-secret'),
|
||||
})
|
||||
const service = new ScriptMonitorService(repository)
|
||||
|
||||
await expect(service.reportSnapshot('agent-1', 'wrong-secret', {
|
||||
connectionKind: 'INTERNET',
|
||||
reportIntervalSec: 60,
|
||||
scripts: [],
|
||||
})).rejects.toMatchObject({ code: 'AUTH_002' })
|
||||
expect(repository.applySnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,215 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
|
||||
import type {
|
||||
ScriptMonitorAgentCredentials,
|
||||
ScriptMonitorOverview,
|
||||
} from '@/lib/supply-chain-script-monitor'
|
||||
import {
|
||||
isScriptMonitorAgentOnline,
|
||||
resolveScriptMonitorEffectiveStatus,
|
||||
} from '@/lib/supply-chain-script-monitor'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type {
|
||||
CreateScriptMonitorAgentInput,
|
||||
ScriptMonitorAgentReportInput,
|
||||
ScriptMonitorRunReportInput,
|
||||
} from '@/server/dto/script-monitor.dto'
|
||||
import {
|
||||
scriptMonitorRepository,
|
||||
type ScriptMonitorRepository,
|
||||
} from '@/server/repositories/script-monitor.repository'
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
function dateOrNull(value?: string | null) {
|
||||
return value ? new Date(value) : null
|
||||
}
|
||||
|
||||
function serializeRun(run: {
|
||||
id: string
|
||||
status: 'RUNNING' | 'SUCCESS' | 'FAILED'
|
||||
trigger: string | null
|
||||
startedAt: Date
|
||||
finishedAt: Date | null
|
||||
durationMs: number | null
|
||||
exitCode: number | null
|
||||
resultSummary: string | null
|
||||
outputTail: string | null
|
||||
errorTail: string | null
|
||||
}) {
|
||||
return {
|
||||
...run,
|
||||
startedAt: run.startedAt.toISOString(),
|
||||
finishedAt: run.finishedAt?.toISOString() ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export class ScriptMonitorService {
|
||||
constructor(private readonly repository: ScriptMonitorRepository = scriptMonitorRepository) {}
|
||||
|
||||
async createAgent(input: CreateScriptMonitorAgentInput) {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const tokenHint = token.slice(-6)
|
||||
const agent = await this.repository.createAgent({
|
||||
name: input.name,
|
||||
networkMode: input.networkMode,
|
||||
tokenHash: hashToken(token),
|
||||
tokenHint,
|
||||
})
|
||||
const credentials: ScriptMonitorAgentCredentials = {
|
||||
agentId: agent.id,
|
||||
token,
|
||||
tokenHint,
|
||||
}
|
||||
return { agent, credentials }
|
||||
}
|
||||
|
||||
private async authenticateAgent(agentId: string, token: string) {
|
||||
if (!agentId || !token) {
|
||||
throw new AppError(ErrorCode.AUTH_INVALID_TOKEN, '缺少脚本监控 Agent 身份信息')
|
||||
}
|
||||
const agent = await this.repository.findAgentAuthentication(agentId)
|
||||
if (!agent?.isActive) {
|
||||
throw new AppError(ErrorCode.AUTH_INVALID_TOKEN, '脚本监控 Agent 不存在或已停用')
|
||||
}
|
||||
const actual = Buffer.from(hashToken(token), 'hex')
|
||||
const expected = Buffer.from(agent.tokenHash, 'hex')
|
||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
||||
throw new AppError(ErrorCode.AUTH_INVALID_TOKEN, '脚本监控 Agent Token 无效')
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
async reportSnapshot(agentId: string, token: string, input: ScriptMonitorAgentReportInput) {
|
||||
const agent = await this.authenticateAgent(agentId, token)
|
||||
const reportedAt = new Date()
|
||||
await this.repository.applySnapshot({
|
||||
agentId: agent.id,
|
||||
hostname: input.hostname,
|
||||
os: input.os,
|
||||
agentVersion: input.agentVersion,
|
||||
connectionKind: input.connectionKind,
|
||||
reportIntervalSec: input.reportIntervalSec,
|
||||
reportedAt,
|
||||
scripts: input.scripts.map((script) => ({
|
||||
...script,
|
||||
lastStartedAt: dateOrNull(script.lastStartedAt),
|
||||
lastFinishedAt: dateOrNull(script.lastFinishedAt),
|
||||
})),
|
||||
})
|
||||
return { ok: true, reportedAt: reportedAt.toISOString(), scriptCount: input.scripts.length }
|
||||
}
|
||||
|
||||
async reportRun(agentId: string, token: string, input: ScriptMonitorRunReportInput) {
|
||||
const agent = await this.authenticateAgent(agentId, token)
|
||||
const reportedAt = new Date()
|
||||
await this.repository.recordRun({
|
||||
agentId: agent.id,
|
||||
scriptKey: input.scriptKey,
|
||||
scriptName: input.scriptName,
|
||||
taskDescription: input.taskDescription,
|
||||
jobType: input.jobType,
|
||||
externalRunId: input.externalRunId,
|
||||
status: input.status,
|
||||
trigger: input.trigger,
|
||||
startedAt: new Date(input.startedAt),
|
||||
finishedAt: dateOrNull(input.finishedAt),
|
||||
durationMs: input.durationMs,
|
||||
exitCode: input.exitCode,
|
||||
resultSummary: input.resultSummary,
|
||||
outputTail: input.outputTail,
|
||||
errorTail: input.errorTail,
|
||||
reportedAt,
|
||||
})
|
||||
return { ok: true, reportedAt: reportedAt.toISOString() }
|
||||
}
|
||||
|
||||
async getOverview(): Promise<ScriptMonitorOverview> {
|
||||
const records = await this.repository.listOverview()
|
||||
const now = Date.now()
|
||||
const agents = records.map((agent) => {
|
||||
const online = isScriptMonitorAgentOnline(agent, now)
|
||||
const abnormalCount = agent.scripts.filter((script) => {
|
||||
const status = resolveScriptMonitorEffectiveStatus(script, now)
|
||||
return status === 'FAILED' || status === 'SILENT'
|
||||
}).length
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
hostname: agent.hostname,
|
||||
networkMode: agent.networkMode,
|
||||
connectionKind: agent.lastConnectionKind,
|
||||
online,
|
||||
isActive: agent.isActive,
|
||||
os: agent.os,
|
||||
agentVersion: agent.agentVersion,
|
||||
reportIntervalSec: agent.reportIntervalSec,
|
||||
lastSeenAt: agent.lastSeenAt?.toISOString() ?? null,
|
||||
scriptCount: agent.scripts.length,
|
||||
abnormalCount,
|
||||
}
|
||||
})
|
||||
const agentById = new Map(agents.map((agent) => [agent.id, agent]))
|
||||
const scripts = records.flatMap((agent) => agent.scripts.map((script) => {
|
||||
const latestRun = script.runs[0] ?? null
|
||||
const resolvedAgent = agentById.get(agent.id)!
|
||||
return {
|
||||
id: script.id,
|
||||
externalKey: script.externalKey,
|
||||
name: script.name,
|
||||
taskDescription: script.taskDescription,
|
||||
jobType: script.jobType,
|
||||
schedule: script.schedule,
|
||||
commandPreview: script.commandPreview,
|
||||
expectedIntervalSec: script.expectedIntervalSec,
|
||||
status: resolveScriptMonitorEffectiveStatus(script, now),
|
||||
isEnabled: script.isEnabled,
|
||||
lastStartedAt: script.lastStartedAt?.toISOString() ?? null,
|
||||
lastFinishedAt: script.lastFinishedAt?.toISOString() ?? null,
|
||||
lastDurationMs: script.lastDurationMs,
|
||||
lastExitCode: script.lastExitCode,
|
||||
lastResultSummary: script.lastResultSummary,
|
||||
lastOutputTail: script.lastOutputTail,
|
||||
lastErrorTail: script.lastErrorTail,
|
||||
lastReportedAt: script.lastReportedAt.toISOString(),
|
||||
agent: {
|
||||
id: resolvedAgent.id,
|
||||
name: resolvedAgent.name,
|
||||
hostname: resolvedAgent.hostname,
|
||||
networkMode: resolvedAgent.networkMode,
|
||||
connectionKind: resolvedAgent.connectionKind,
|
||||
online: resolvedAgent.online,
|
||||
lastSeenAt: resolvedAgent.lastSeenAt,
|
||||
},
|
||||
latestRun: latestRun ? serializeRun(latestRun) : null,
|
||||
}
|
||||
}))
|
||||
const healthy = scripts.filter((script) => script.status === 'SUCCESS').length
|
||||
const running = scripts.filter((script) => script.status === 'RUNNING').length
|
||||
const abnormal = scripts.filter((script) => script.status === 'FAILED' || script.status === 'SILENT').length
|
||||
const unknown = scripts.filter((script) => script.status === 'UNKNOWN').length
|
||||
|
||||
return {
|
||||
generatedAt: new Date(now).toISOString(),
|
||||
agents,
|
||||
scripts,
|
||||
metrics: {
|
||||
agentTotal: agents.length,
|
||||
agentOnline: agents.filter((agent) => agent.online).length,
|
||||
scriptTotal: scripts.length,
|
||||
healthy,
|
||||
running,
|
||||
abnormal,
|
||||
unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async getRuns(scriptId: string, limit = 30) {
|
||||
const records = await this.repository.listRuns(scriptId, Math.min(100, Math.max(1, limit)))
|
||||
return records.map(serializeRun)
|
||||
}
|
||||
}
|
||||
|
||||
export const scriptMonitorService = new ScriptMonitorService()
|
||||
@ -119,6 +119,7 @@ class SupplyChainPermissionSettingsService {
|
||||
'purchaseSupplierInformation',
|
||||
'purchaseDailyAffairs',
|
||||
'purchasePriceChange',
|
||||
'scriptMonitor',
|
||||
'operationLogs',
|
||||
] satisfies SupplyChainPermissionTargetKey[]
|
||||
}
|
||||
@ -140,6 +141,7 @@ class SupplyChainPermissionSettingsService {
|
||||
'purchaseSupplierInformation',
|
||||
'purchaseDailyAffairs',
|
||||
'purchasePriceChange',
|
||||
'scriptMonitor',
|
||||
'operationLogs',
|
||||
]
|
||||
return targetKeys.filter((targetKey) =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user