release: merge latest development with script monitoring

This commit is contained in:
WuYongJie 2026-08-28 15:51:55 +08:00
commit 422c23d3e5
28 changed files with 2361 additions and 0 deletions

View File

@ -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

View 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`,该文件已被仓库忽略。

View 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)
})
}

View File

@ -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
}
}
]
}

View File

@ -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`,
},
}],
}

View 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
})

View File

@ -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;

View File

@ -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)])
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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,

View File

@ -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': '用户权限',
}

View File

@ -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({

View File

@ -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: '操作日志',

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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}
/>
)
}

View File

@ -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', () => {

View File

@ -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
}

View File

@ -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')
})
})

View 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
}

View 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>

View File

@ -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()

View File

@ -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()
})
})

View File

@ -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()

View File

@ -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) =>