merge: release script monitor operations and store account completion
# Conflicts: # furniture-3d-system/prisma/schema.prisma # furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/bank-account/route.ts # furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/import/route.ts # furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/route.ts # furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/template/route.ts
This commit is contained in:
commit
85c31677c5
@ -8,6 +8,11 @@
|
|||||||
2. 复制本目录到目标机器,把 `config.example.json` 复制为 `config.json`,填入凭据、内外网地址和脚本任务说明。
|
2. 复制本目录到目标机器,把 `config.example.json` 复制为 `config.json`,填入凭据、内外网地址和脚本任务说明。
|
||||||
3. 先运行 `node agent.mjs` 确认上报,再使用 `pm2 start ecosystem.config.cjs` 常驻。
|
3. 先运行 `node agent.mjs` 确认上报,再使用 `pm2 start ecosystem.config.cjs` 常驻。
|
||||||
|
|
||||||
|
需要在平台点击“立即运行”的脚本,必须在 `config.json` 对应任务中显式配置
|
||||||
|
`"allowManualRun": true`。平台只会下发脚本 `key`,Agent 始终执行本机配置里预存的
|
||||||
|
`command`,不会接收平台传来的任意命令。Agent 默认每 5 秒主动领取一次执行请求,
|
||||||
|
因此内网机器不需要开放入站端口。
|
||||||
|
|
||||||
`connectionMode` 支持:
|
`connectionMode` 支持:
|
||||||
|
|
||||||
- `intranet`:只走内网地址。
|
- `intranet`:只走内网地址。
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { execFileSync } from 'node:child_process'
|
import { execFileSync, spawn } from 'node:child_process'
|
||||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||||
import { hostname, platform, release } from 'node:os'
|
import { hostname, platform, release } from 'node:os'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
@ -26,6 +26,7 @@ export function loadConfig(configPath = process.env.SCRIPT_MONITOR_CONFIG || res
|
|||||||
}
|
}
|
||||||
config.reportIntervalSec = Math.max(15, Number(config.reportIntervalSec) || 60)
|
config.reportIntervalSec = Math.max(15, Number(config.reportIntervalSec) || 60)
|
||||||
config.requestTimeoutMs = Math.max(1000, Number(config.requestTimeoutMs) || 10000)
|
config.requestTimeoutMs = Math.max(1000, Number(config.requestTimeoutMs) || 10000)
|
||||||
|
config.commandPollIntervalSec = Math.min(60, Math.max(2, Number(config.commandPollIntervalSec) || 5))
|
||||||
config.scripts = Array.isArray(config.scripts) ? config.scripts : []
|
config.scripts = Array.isArray(config.scripts) ? config.scripts : []
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
@ -120,8 +121,9 @@ function scanPm2() {
|
|||||||
schedule: null,
|
schedule: null,
|
||||||
commandPreview: redactCommand(processInfo.pm2_env?.pm_exec_path || ''),
|
commandPreview: redactCommand(processInfo.pm2_env?.pm_exec_path || ''),
|
||||||
expectedIntervalSec: null,
|
expectedIntervalSec: null,
|
||||||
status,
|
status,
|
||||||
isEnabled: status !== 'DISABLED',
|
isEnabled: status !== 'DISABLED',
|
||||||
|
canManualRun: false,
|
||||||
lastStartedAt: processInfo.pm2_env?.pm_uptime
|
lastStartedAt: processInfo.pm2_env?.pm_uptime
|
||||||
? new Date(processInfo.pm2_env.pm_uptime).toISOString()
|
? new Date(processInfo.pm2_env.pm_uptime).toISOString()
|
||||||
: null,
|
: null,
|
||||||
@ -152,6 +154,7 @@ function parseCronLine(line) {
|
|||||||
expectedIntervalSec: null,
|
expectedIntervalSec: null,
|
||||||
status: 'UNKNOWN',
|
status: 'UNKNOWN',
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
|
canManualRun: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,6 +232,7 @@ function normalizeConfiguredScript(script) {
|
|||||||
expectedIntervalSec: script.expectedIntervalSec ? Number(script.expectedIntervalSec) : null,
|
expectedIntervalSec: script.expectedIntervalSec ? Number(script.expectedIntervalSec) : null,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
isEnabled: script.enabled !== false,
|
isEnabled: script.enabled !== false,
|
||||||
|
canManualRun: script.allowManualRun === true && Boolean(script.command),
|
||||||
lastResultSummary: result.summary,
|
lastResultSummary: result.summary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -260,6 +264,110 @@ export async function reportSnapshot(config) {
|
|||||||
console.log(`[script-monitor] 已通过${result.endpoint.kind === 'INTRANET' ? '内网' : '公网'}上报 ${scripts.length} 个脚本`)
|
console.log(`[script-monitor] 已通过${result.endpoint.kind === 'INTRANET' ? '内网' : '公网'}上报 ${scripts.length} 个脚本`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function keepTail(current, chunk, limit = 12000) {
|
||||||
|
const next = current + chunk.toString()
|
||||||
|
return next.length > limit ? next.slice(-limit) : next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportManualRun(config, command, input) {
|
||||||
|
return postToCenter(config, '/api/internal/script-monitor/run', {
|
||||||
|
commandId: command.id,
|
||||||
|
scriptKey: command.scriptKey,
|
||||||
|
scriptName: command.scriptName,
|
||||||
|
taskDescription: command.taskDescription || '',
|
||||||
|
jobType: String(command.jobType || 'OTHER').toUpperCase(),
|
||||||
|
...input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeManualCommand(config, command) {
|
||||||
|
const script = config.scripts.find((item) => item.key === command.scriptKey)
|
||||||
|
const startedAt = new Date()
|
||||||
|
const externalRunId = `manual:${command.id}`
|
||||||
|
if (!script?.command || script.allowManualRun !== true) {
|
||||||
|
const message = 'Agent 配置未开放该脚本的手动运行权限'
|
||||||
|
await reportManualRun(config, command, {
|
||||||
|
externalRunId,
|
||||||
|
status: 'FAILED',
|
||||||
|
trigger: 'manual',
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
durationMs: 0,
|
||||||
|
exitCode: 1,
|
||||||
|
resultSummary: message,
|
||||||
|
errorTail: message,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await reportManualRun(config, command, {
|
||||||
|
externalRunId,
|
||||||
|
status: 'RUNNING',
|
||||||
|
trigger: 'manual',
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
})
|
||||||
|
let outputTail = ''
|
||||||
|
let errorTail = ''
|
||||||
|
try {
|
||||||
|
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 reportManualRun(config, command, {
|
||||||
|
externalRunId,
|
||||||
|
status: exitCode === 0 ? 'SUCCESS' : 'FAILED',
|
||||||
|
trigger: 'manual',
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
finishedAt: finishedAt.toISOString(),
|
||||||
|
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
||||||
|
exitCode,
|
||||||
|
resultSummary: exitCode === 0 ? '手动运行成功' : `手动运行失败,退出码 ${exitCode}`,
|
||||||
|
outputTail,
|
||||||
|
errorTail,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const finishedAt = new Date()
|
||||||
|
await reportManualRun(config, command, {
|
||||||
|
externalRunId,
|
||||||
|
status: 'FAILED',
|
||||||
|
trigger: 'manual',
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
finishedAt: finishedAt.toISOString(),
|
||||||
|
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
||||||
|
exitCode: 1,
|
||||||
|
resultSummary: `手动运行启动失败:${error.message}`,
|
||||||
|
errorTail: error.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollManualCommand(config) {
|
||||||
|
const result = await postToCenter(
|
||||||
|
config,
|
||||||
|
'/api/internal/script-monitor/commands/claim',
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
const command = result.body?.data?.command
|
||||||
|
if (!command) return false
|
||||||
|
await executeManualCommand(config, command)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig()
|
const config = loadConfig()
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
@ -269,10 +377,25 @@ async function main() {
|
|||||||
console.error('[script-monitor] 上报失败:', error.message)
|
console.error('[script-monitor] 上报失败:', error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let commandBusy = false
|
||||||
|
const poll = async () => {
|
||||||
|
if (commandBusy) return
|
||||||
|
commandBusy = true
|
||||||
|
try {
|
||||||
|
await pollManualCommand(config)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[script-monitor] 手动任务轮询失败:', error.message)
|
||||||
|
} finally {
|
||||||
|
commandBusy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
await run()
|
await run()
|
||||||
|
await poll()
|
||||||
const timer = setInterval(run, config.reportIntervalSec * 1000)
|
const timer = setInterval(run, config.reportIntervalSec * 1000)
|
||||||
|
const commandTimer = setInterval(poll, config.commandPollIntervalSec * 1000)
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
|
clearInterval(commandTimer)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
process.on('SIGINT', stop)
|
process.on('SIGINT', stop)
|
||||||
|
|||||||
79
furniture-3d-system/agents/script-monitor/agent.test.mjs
Normal file
79
furniture-3d-system/agents/script-monitor/agent.test.mjs
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { executeManualCommand } from './agent.mjs'
|
||||||
|
|
||||||
|
function config(overrides = {}) {
|
||||||
|
return {
|
||||||
|
agentId: 'agent-1',
|
||||||
|
agentToken: 'secret-token',
|
||||||
|
connectionMode: 'internet',
|
||||||
|
centerUrls: { internet: 'https://monitor.example.test' },
|
||||||
|
requestTimeoutMs: 5000,
|
||||||
|
scripts: [{
|
||||||
|
key: 'local-check',
|
||||||
|
name: '本机检查',
|
||||||
|
taskDescription: '验证手动触发',
|
||||||
|
jobType: 'MANUAL',
|
||||||
|
allowManualRun: true,
|
||||||
|
command: `${JSON.stringify(process.execPath)} -e "console.log('manual-ok')"`,
|
||||||
|
}],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function command() {
|
||||||
|
return {
|
||||||
|
id: 'command-1',
|
||||||
|
scriptKey: 'local-check',
|
||||||
|
scriptName: '本机检查',
|
||||||
|
taskDescription: '验证手动触发',
|
||||||
|
jobType: 'MANUAL',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('manual command executes only the local configured command and reports its result', async () => {
|
||||||
|
const previousFetch = globalThis.fetch
|
||||||
|
const reports = []
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
reports.push(JSON.parse(init.body))
|
||||||
|
return new Response(JSON.stringify({ success: true, data: { ok: true } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await executeManualCommand(config(), command())
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = previousFetch
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(reports.length, 2)
|
||||||
|
assert.equal(reports[0].status, 'RUNNING')
|
||||||
|
assert.equal(reports[1].status, 'SUCCESS')
|
||||||
|
assert.equal(reports[1].exitCode, 0)
|
||||||
|
assert.match(reports[1].outputTail, /manual-ok/)
|
||||||
|
assert.equal(reports[1].commandId, 'command-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manual command is rejected when allowManualRun is not explicitly enabled', async () => {
|
||||||
|
const previousFetch = globalThis.fetch
|
||||||
|
const reports = []
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
reports.push(JSON.parse(init.body))
|
||||||
|
return new Response(JSON.stringify({ success: true, data: { ok: true } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const blockedConfig = config()
|
||||||
|
blockedConfig.scripts[0].allowManualRun = false
|
||||||
|
try {
|
||||||
|
await executeManualCommand(blockedConfig, command())
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = previousFetch
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(reports.length, 1)
|
||||||
|
assert.equal(reports[0].status, 'FAILED')
|
||||||
|
assert.match(reports[0].resultSummary, /未开放/)
|
||||||
|
})
|
||||||
@ -8,6 +8,7 @@
|
|||||||
"internet": "https://cp.jiansun.vip"
|
"internet": "https://cp.jiansun.vip"
|
||||||
},
|
},
|
||||||
"reportIntervalSec": 60,
|
"reportIntervalSec": 60,
|
||||||
|
"commandPollIntervalSec": 5,
|
||||||
"requestTimeoutMs": 10000,
|
"requestTimeoutMs": 10000,
|
||||||
"autoDiscover": {
|
"autoDiscover": {
|
||||||
"cron": true,
|
"cron": true,
|
||||||
@ -21,6 +22,7 @@
|
|||||||
"jobType": "CRON",
|
"jobType": "CRON",
|
||||||
"schedule": "*/15 * * * *",
|
"schedule": "*/15 * * * *",
|
||||||
"expectedIntervalSec": 900,
|
"expectedIntervalSec": 900,
|
||||||
|
"allowManualRun": true,
|
||||||
"command": "npm run sync:supply-chain-product-archive",
|
"command": "npm run sync:supply-chain-product-archive",
|
||||||
"cwd": "/opt/furniture-3d-system-prod/current",
|
"cwd": "/opt/furniture-3d-system-prod/current",
|
||||||
"resultCheck": {
|
"resultCheck": {
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ScriptMonitorCommandStatus" AS ENUM ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED', 'EXPIRED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ScriptMonitorScript"
|
||||||
|
ADD COLUMN "canManualRun" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ScriptMonitorCommand" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"agentId" TEXT NOT NULL,
|
||||||
|
"scriptId" TEXT NOT NULL,
|
||||||
|
"status" "ScriptMonitorCommandStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"requestedById" TEXT,
|
||||||
|
"requestedByName" TEXT,
|
||||||
|
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"externalRunId" TEXT,
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "ScriptMonitorCommand_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ScriptMonitorCommand_agentId_status_requestedAt_idx" ON "ScriptMonitorCommand"("agentId", "status", "requestedAt");
|
||||||
|
CREATE INDEX "ScriptMonitorCommand_scriptId_requestedAt_idx" ON "ScriptMonitorCommand"("scriptId", "requestedAt" DESC);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ScriptMonitorCommand" ADD CONSTRAINT "ScriptMonitorCommand_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "ScriptMonitorAgent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ScriptMonitorCommand" ADD CONSTRAINT "ScriptMonitorCommand_scriptId_fkey" FOREIGN KEY ("scriptId") REFERENCES "ScriptMonitorScript"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -3512,6 +3512,7 @@ model StoreAccount {
|
|||||||
@@index([accountType])
|
@@index([accountType])
|
||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model StoreAccountStoreProfile {
|
model StoreAccountStoreProfile {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
storeName String
|
storeName String
|
||||||
@ -3529,6 +3530,7 @@ model StoreAccountStoreProfile {
|
|||||||
@@index([storeName])
|
@@index([storeName])
|
||||||
@@index([platform])
|
@@index([platform])
|
||||||
}
|
}
|
||||||
|
|
||||||
model StoreAccountChangeLog {
|
model StoreAccountChangeLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
storeAccountId String?
|
storeAccountId String?
|
||||||
@ -3987,6 +3989,14 @@ enum ScriptMonitorRunStatus {
|
|||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ScriptMonitorCommandStatus {
|
||||||
|
PENDING
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
model ScriptMonitorAgent {
|
model ScriptMonitorAgent {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@ -4004,6 +4014,7 @@ model ScriptMonitorAgent {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
scripts ScriptMonitorScript[]
|
scripts ScriptMonitorScript[]
|
||||||
runs ScriptMonitorRun[]
|
runs ScriptMonitorRun[]
|
||||||
|
commands ScriptMonitorCommand[]
|
||||||
|
|
||||||
@@index([isActive, lastSeenAt])
|
@@index([isActive, lastSeenAt])
|
||||||
}
|
}
|
||||||
@ -4021,6 +4032,7 @@ model ScriptMonitorScript {
|
|||||||
expectedIntervalSec Int?
|
expectedIntervalSec Int?
|
||||||
status ScriptMonitorJobStatus @default(UNKNOWN)
|
status ScriptMonitorJobStatus @default(UNKNOWN)
|
||||||
isEnabled Boolean @default(true)
|
isEnabled Boolean @default(true)
|
||||||
|
canManualRun Boolean @default(false)
|
||||||
lastStartedAt DateTime?
|
lastStartedAt DateTime?
|
||||||
lastFinishedAt DateTime?
|
lastFinishedAt DateTime?
|
||||||
lastDurationMs Int?
|
lastDurationMs Int?
|
||||||
@ -4032,6 +4044,7 @@ model ScriptMonitorScript {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
runs ScriptMonitorRun[]
|
runs ScriptMonitorRun[]
|
||||||
|
commands ScriptMonitorCommand[]
|
||||||
|
|
||||||
@@unique([agentId, externalKey])
|
@@unique([agentId, externalKey])
|
||||||
@@index([agentId, status])
|
@@index([agentId, status])
|
||||||
@ -4060,3 +4073,23 @@ model ScriptMonitorRun {
|
|||||||
@@index([scriptId, startedAt(sort: Desc)])
|
@@index([scriptId, startedAt(sort: Desc)])
|
||||||
@@index([agentId, startedAt(sort: Desc)])
|
@@index([agentId, startedAt(sort: Desc)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ScriptMonitorCommand {
|
||||||
|
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)
|
||||||
|
status ScriptMonitorCommandStatus @default(PENDING)
|
||||||
|
requestedById String?
|
||||||
|
requestedByName String?
|
||||||
|
requestedAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
claimedAt DateTime?
|
||||||
|
finishedAt DateTime?
|
||||||
|
externalRunId String?
|
||||||
|
errorMessage String? @db.Text
|
||||||
|
|
||||||
|
@@index([agentId, status, requestedAt])
|
||||||
|
@@index([scriptId, requestedAt(sort: Desc)])
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||||
|
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 { agentId, token } = readAgentCredentials(request)
|
||||||
|
return jsonSuccess(
|
||||||
|
await scriptMonitorService.claimCommand(agentId, token),
|
||||||
|
200,
|
||||||
|
ctx.requestId,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ requireAuth: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||||
|
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||||
|
import { scriptMonitorService } from '@/server/services/script-monitor.service'
|
||||||
|
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
return handleRequest(
|
||||||
|
request,
|
||||||
|
async (ctx) => {
|
||||||
|
const access = await supplyChainPermissionSettingsService.getUserAccess(
|
||||||
|
ctx.user,
|
||||||
|
'scriptMonitor',
|
||||||
|
)
|
||||||
|
if (!access.edit) {
|
||||||
|
throw new AppError(
|
||||||
|
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||||
|
'没有手动运行脚本的权限',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const { id } = await context.params
|
||||||
|
return jsonSuccess(
|
||||||
|
await scriptMonitorService.requestManualRun(id, {
|
||||||
|
id: ctx.user.id,
|
||||||
|
name: ctx.user.name,
|
||||||
|
}),
|
||||||
|
202,
|
||||||
|
ctx.requestId,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ requireAuth: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -7,6 +7,8 @@ type RouteProps = { params: Promise<{ id: string }> }
|
|||||||
export async function GET(request: NextRequest, props: RouteProps) {
|
export async function GET(request: NextRequest, props: RouteProps) {
|
||||||
return handleRequest(request, async (ctx) => {
|
return handleRequest(request, async (ctx) => {
|
||||||
const { id } = await props.params
|
const { id } = await props.params
|
||||||
return jsonSuccess(await storeAccountManagementService.revealBankAccount(id, ctx.user), 200, ctx.requestId)
|
const response = jsonSuccess(await storeAccountManagementService.revealBankAccount(id, ctx.user), 200, ctx.requestId)
|
||||||
|
response.headers.set('Cache-Control', 'no-store')
|
||||||
|
return response
|
||||||
}, { requireAuth: true })
|
}, { requireAuth: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,11 @@ const updateSchema = z.object({
|
|||||||
operationDepartmentHead: z.string().trim().max(240).optional(),
|
operationDepartmentHead: z.string().trim().max(240).optional(),
|
||||||
operationDirector: z.string().trim().max(240).optional(),
|
operationDirector: z.string().trim().max(240).optional(),
|
||||||
profitCenter: z.string().trim().max(240).optional(),
|
profitCenter: z.string().trim().max(240).optional(),
|
||||||
|
subject: z.string().trim().max(240).optional(),
|
||||||
accountType: z.string().trim().max(20).optional(),
|
accountType: z.string().trim().max(20).optional(),
|
||||||
account: z.string().trim().max(240).optional(),
|
account: z.string().trim().max(240).optional(),
|
||||||
password: z.string().max(500).optional(),
|
password: z.string().max(500).optional(),
|
||||||
|
boundBankAccountNumber: z.string().trim().max(500).optional(),
|
||||||
boundPhone: z.string().trim().max(80).optional(),
|
boundPhone: z.string().trim().max(80).optional(),
|
||||||
verificationContact: z.string().trim().max(240).optional(),
|
verificationContact: z.string().trim().max(240).optional(),
|
||||||
jstOrderService: z.string().trim().max(240).optional(),
|
jstOrderService: z.string().trim().max(240).optional(),
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import { handleRequest, jsonError } from '@/server/core/response'
|
|||||||
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
|
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
|
||||||
|
|
||||||
const HEADER_MAP: Record<string, string> = {
|
const HEADER_MAP: Record<string, string> = {
|
||||||
店铺名称: 'storeName', 店铺运营负责人: 'operationOwner', 店铺运营部门负责人: 'operationDepartmentHead', 店铺运营总监: 'operationDirector', 店铺所属利润中心: 'profitCenter', 账号类型: 'accountType', 账号: 'account', 密码: 'password', 绑定手机号: 'boundPhone', 手机号验证码对接人: 'verificationContact', 聚水潭订单服务号: 'jstOrderService', 账号分组名称: 'accountGroupName',
|
店铺名称: 'storeName', 店铺运营负责人: 'operationOwner', 店铺运营部门负责人: 'operationDepartmentHead', 店铺运营总监: 'operationDirector', 店铺所属利润中心: 'profitCenter', 主体: 'subject', 账号类型: 'accountType', 账号: 'account', 密码: 'password', 店铺绑定银行卡号: 'boundBankAccountNumber', 绑定手机号: 'boundPhone', 手机号验证码对接人: 'verificationContact', 聚水潭订单服务号: 'jstOrderService', 账号分组名称: 'accountGroupName',
|
||||||
storeName: 'storeName', operationOwner: 'operationOwner', operationDepartmentHead: 'operationDepartmentHead', operationDirector: 'operationDirector', profitCenter: 'profitCenter', accountType: 'accountType', account: 'account', password: 'password', boundPhone: 'boundPhone', verificationContact: 'verificationContact', jstOrderService: 'jstOrderService', accountGroupName: 'accountGroupName',
|
storeName: 'storeName', operationOwner: 'operationOwner', operationDepartmentHead: 'operationDepartmentHead', operationDirector: 'operationDirector', profitCenter: 'profitCenter', subject: 'subject', accountType: 'accountType', account: 'account', password: 'password', boundBankAccountNumber: 'boundBankAccountNumber', boundPhone: 'boundPhone', verificationContact: 'verificationContact', jstOrderService: 'jstOrderService', accountGroupName: 'accountGroupName',
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
@ -1,39 +1,58 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
|
import {
|
||||||
|
STORE_ACCOUNT_PROFILE_FIELDS,
|
||||||
|
STORE_ACCOUNT_PROFILE_PLATFORMS,
|
||||||
|
type StoreAccountProfilePlatform,
|
||||||
|
} from '@/lib/store-account-management'
|
||||||
import { ErrorCode } from '@/server/core/errors'
|
import { ErrorCode } from '@/server/core/errors'
|
||||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||||
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
|
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
|
||||||
import { STORE_ACCOUNT_PROFILE_FIELDS } from '@/lib/store-account-management'
|
|
||||||
|
|
||||||
const HEADER_MAP: Record<string, string> = Object.fromEntries([
|
const HEADER_MAP = new Map<string, string>([
|
||||||
['店铺名称', 'storeName'],
|
['店铺名称', 'storeName'],
|
||||||
...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => [field.label, field.key]),
|
['storeName', 'storeName'],
|
||||||
|
...STORE_ACCOUNT_PROFILE_FIELDS.flatMap((field) => [
|
||||||
|
[field.label, field.key],
|
||||||
|
[field.key, field.key],
|
||||||
|
] as Array<[string, string]>),
|
||||||
])
|
])
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
return handleRequest(request, async (ctx) => {
|
return handleRequest(request, async (ctx) => {
|
||||||
const form = await request.formData()
|
const form = await request.formData()
|
||||||
const file = form.get('file')
|
const file = form.get('file')
|
||||||
const platform = String(form.get('platform') ?? '')
|
const platform = String(form.get('platform') ?? '').trim().toUpperCase()
|
||||||
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
|
if (!(file instanceof File)) {
|
||||||
if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId)
|
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
|
||||||
|
}
|
||||||
|
if (!STORE_ACCOUNT_PROFILE_PLATFORMS.some((item) => item.key === platform)) {
|
||||||
|
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜', 400, ctx.requestId)
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId)
|
||||||
|
}
|
||||||
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: false })
|
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: false })
|
||||||
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
||||||
if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
|
if (!sheet) {
|
||||||
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
|
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
|
||||||
if (rawRows.length === 0) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
|
|
||||||
if (rawRows.length > 5000) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '单次最多导入5000行', 400, ctx.requestId)
|
|
||||||
let processed = 0
|
|
||||||
for (const raw of rawRows) {
|
|
||||||
const mapped = Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => {
|
|
||||||
const key = HEADER_MAP[header.trim()]
|
|
||||||
return key ? [[key, value]] : []
|
|
||||||
}))
|
|
||||||
const storeName = String(mapped.storeName ?? '').trim()
|
|
||||||
if (!storeName) throw new Error(`第${processed + 2}行缺少店铺名称`)
|
|
||||||
await storeAccountManagementService.saveStoreProfile({ storeName, platform, values: mapped, user: ctx.user })
|
|
||||||
processed += 1
|
|
||||||
}
|
}
|
||||||
return jsonSuccess({ processed, total: rawRows.length }, 200, ctx.requestId)
|
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
|
||||||
|
const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => {
|
||||||
|
const key = HEADER_MAP.get(header.trim())
|
||||||
|
return key ? [[key, value]] : []
|
||||||
|
})))
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
|
||||||
|
}
|
||||||
|
if (rows.length > 5000) {
|
||||||
|
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '单次最多导入5000行', 400, ctx.requestId)
|
||||||
|
}
|
||||||
|
const result = await storeAccountManagementService.importStoreProfiles({
|
||||||
|
platform: platform as StoreAccountProfilePlatform,
|
||||||
|
rows,
|
||||||
|
user: ctx.user,
|
||||||
|
})
|
||||||
|
return jsonSuccess(result, 200, ctx.requestId)
|
||||||
}, { requireAuth: true })
|
}, { requireAuth: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,15 @@ import { ErrorCode } from '@/server/core/errors'
|
|||||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||||
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
|
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
|
||||||
|
|
||||||
const schema = z.object({
|
const profileSchema = z.object({
|
||||||
storeName: z.string().trim().min(1).max(240),
|
storeName: z.string().trim().min(1).max(240),
|
||||||
platform: z.string().trim().min(1).max(10),
|
platform: z.enum(['JD', 'JX']),
|
||||||
values: z.record(z.string(), z.unknown()).default({}),
|
values: z.object({
|
||||||
|
merchantNumber: z.string().trim().max(500).optional(),
|
||||||
|
secondaryMerchantNumber: z.string().trim().max(500).optional(),
|
||||||
|
merchantStoreId: z.string().trim().max(500).optional(),
|
||||||
|
companyId: z.string().trim().max(500).optional(),
|
||||||
|
}).strict(),
|
||||||
}).strict()
|
}).strict()
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@ -19,8 +24,14 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
return handleRequest(request, async (ctx) => {
|
return handleRequest(request, async (ctx) => {
|
||||||
const parsed = schema.safeParse(await request.json().catch(() => null))
|
const body = await request.json().catch(() => null)
|
||||||
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺资料内容不正确', 400, ctx.requestId)
|
const parsed = profileSchema.safeParse(body)
|
||||||
return jsonSuccess(await storeAccountManagementService.saveStoreProfile({ ...parsed.data, user: ctx.user }), 200, ctx.requestId)
|
if (!parsed.success) {
|
||||||
|
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺资料内容不正确', 400, ctx.requestId)
|
||||||
|
}
|
||||||
|
return jsonSuccess(await storeAccountManagementService.saveStoreProfile({
|
||||||
|
...parsed.data,
|
||||||
|
user: ctx.user,
|
||||||
|
}), 200, ctx.requestId)
|
||||||
}, { requireAuth: true })
|
}, { requireAuth: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,39 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import ExcelJS from 'exceljs'
|
import * as XLSX from 'xlsx'
|
||||||
import { STORE_ACCOUNT_PROFILE_FIELDS, STORE_ACCOUNT_PROFILE_PLATFORMS, type StoreAccountProfilePlatform } from '@/lib/store-account-management'
|
import {
|
||||||
import { ErrorCode } from '@/server/core/errors'
|
STORE_ACCOUNT_PROFILE_FIELDS,
|
||||||
import { handleRequest, jsonError } from '@/server/core/response'
|
STORE_ACCOUNT_PROFILE_PLATFORMS,
|
||||||
import { storeAccountManagementAccessService } from '@/server/services/store-account-management-access.service'
|
type StoreAccountProfilePlatform,
|
||||||
|
} from '@/lib/store-account-management'
|
||||||
|
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||||
|
import { handleRequest } from '@/server/core/response'
|
||||||
|
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
return handleRequest(request, async (ctx) => {
|
return handleRequest(request, async (ctx) => {
|
||||||
const capabilities = await storeAccountManagementAccessService.getCapabilities(ctx.user)
|
const platform = request.nextUrl.searchParams.get('platform')?.trim().toUpperCase()
|
||||||
if (!capabilities.pageAccess.edit) return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺资料的权限', 403, ctx.requestId)
|
if (!STORE_ACCOUNT_PROFILE_PLATFORMS.some((item) => item.key === platform)) {
|
||||||
const platform = request.nextUrl.searchParams.get('platform')?.toUpperCase() as StoreAccountProfilePlatform
|
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜')
|
||||||
const platformLabel = STORE_ACCOUNT_PROFILE_PLATFORMS.find((item) => item.key === platform)?.label
|
}
|
||||||
if (!platformLabel) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜', 400, ctx.requestId)
|
const { target } = await storeAccountManagementService.getFieldAccess(ctx.user)
|
||||||
const workbook = new ExcelJS.Workbook()
|
if (!target.view) {
|
||||||
const sheet = workbook.addWorksheet(`${platformLabel}店铺资料`)
|
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有店铺账号管理权限')
|
||||||
sheet.columns = [{ header: '店铺名称', key: 'storeName', width: 28 }, ...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => ({ header: field.label, key: field.key, width: 28 }))]
|
}
|
||||||
sheet.views = [{ state: 'frozen', ySplit: 1 }]
|
const normalizedPlatform = platform as StoreAccountProfilePlatform
|
||||||
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }
|
const label = normalizedPlatform === 'JD' ? '京东' : '京喜'
|
||||||
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF27272A' } }
|
const headers = ['店铺名称', ...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => field.label)]
|
||||||
const content = Buffer.from(await workbook.xlsx.writeBuffer())
|
const sheet = XLSX.utils.aoa_to_sheet([headers])
|
||||||
|
sheet['!cols'] = headers.map((header) => ({ wch: Math.max(18, header.length * 2 + 4) }))
|
||||||
|
const workbook = XLSX.utils.book_new()
|
||||||
|
XLSX.utils.book_append_sheet(workbook, sheet, `${label}店铺资料`)
|
||||||
|
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
|
||||||
return new NextResponse(new Uint8Array(content), {
|
return new NextResponse(new Uint8Array(content), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(`${platformLabel}店铺资料导入模板.xlsx`)}`,
|
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(`${label}店铺资料导入模板.xlsx`)}`,
|
||||||
'Cache-Control': 'private, no-store, max-age=0',
|
'Cache-Control': 'private, no-store, max-age=0',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, { requireAuth: true })
|
}, { requireAuth: true })
|
||||||
|
|||||||
@ -11,9 +11,11 @@ const createSchema = z.object({
|
|||||||
operationDepartmentHead: z.string().trim().max(240).optional(),
|
operationDepartmentHead: z.string().trim().max(240).optional(),
|
||||||
operationDirector: z.string().trim().max(240).optional(),
|
operationDirector: z.string().trim().max(240).optional(),
|
||||||
profitCenter: z.string().trim().max(240).optional(),
|
profitCenter: z.string().trim().max(240).optional(),
|
||||||
|
subject: z.string().trim().max(240).optional(),
|
||||||
accountType: z.string().trim().min(1).max(20),
|
accountType: z.string().trim().min(1).max(20),
|
||||||
account: z.string().trim().min(1).max(240),
|
account: z.string().trim().min(1).max(240),
|
||||||
password: z.string().max(500).optional(),
|
password: z.string().max(500).optional(),
|
||||||
|
boundBankAccountNumber: z.string().trim().max(500).optional(),
|
||||||
boundPhone: z.string().trim().max(80).optional(),
|
boundPhone: z.string().trim().max(80).optional(),
|
||||||
verificationContact: z.string().trim().max(240).optional(),
|
verificationContact: z.string().trim().max(240).optional(),
|
||||||
jstOrderService: z.string().trim().max(240).optional(),
|
jstOrderService: z.string().trim().max(240).optional(),
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const overview: ScriptMonitorOverview = {
|
|||||||
hostname: 'warehouse-01',
|
hostname: 'warehouse-01',
|
||||||
networkMode: 'AUTO',
|
networkMode: 'AUTO',
|
||||||
connectionKind: 'INTRANET',
|
connectionKind: 'INTRANET',
|
||||||
|
connectionState: 'ONLINE',
|
||||||
online: true,
|
online: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
os: 'linux 6.8',
|
os: 'linux 6.8',
|
||||||
@ -30,6 +31,7 @@ const overview: ScriptMonitorOverview = {
|
|||||||
expectedIntervalSec: 900,
|
expectedIntervalSec: 900,
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
|
canManualRun: true,
|
||||||
lastStartedAt: '2026-08-28T11:45:00.000Z',
|
lastStartedAt: '2026-08-28T11:45:00.000Z',
|
||||||
lastFinishedAt: '2026-08-28T11:45:12.000Z',
|
lastFinishedAt: '2026-08-28T11:45:12.000Z',
|
||||||
lastDurationMs: 12_000,
|
lastDurationMs: 12_000,
|
||||||
@ -44,14 +46,18 @@ const overview: ScriptMonitorOverview = {
|
|||||||
hostname: 'warehouse-01',
|
hostname: 'warehouse-01',
|
||||||
networkMode: 'AUTO',
|
networkMode: 'AUTO',
|
||||||
connectionKind: 'INTRANET',
|
connectionKind: 'INTRANET',
|
||||||
|
connectionState: 'ONLINE',
|
||||||
online: true,
|
online: true,
|
||||||
lastSeenAt: '2026-08-28T11:59:30.000Z',
|
lastSeenAt: '2026-08-28T11:59:30.000Z',
|
||||||
},
|
},
|
||||||
latestRun: null,
|
latestRun: null,
|
||||||
|
latestCommand: null,
|
||||||
}],
|
}],
|
||||||
metrics: {
|
metrics: {
|
||||||
agentTotal: 1,
|
agentTotal: 1,
|
||||||
|
agentConnected: 1,
|
||||||
agentOnline: 1,
|
agentOnline: 1,
|
||||||
|
agentPending: 0,
|
||||||
scriptTotal: 1,
|
scriptTotal: 1,
|
||||||
healthy: 1,
|
healthy: 1,
|
||||||
running: 0,
|
running: 0,
|
||||||
@ -67,9 +73,11 @@ describe('ScriptMonitorView', () => {
|
|||||||
expect(screen.getByRole('heading', { name: '脚本监控' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: '脚本监控' })).toBeInTheDocument()
|
||||||
expect(screen.getByText('供应链产品档案同步')).toBeInTheDocument()
|
expect(screen.getByText('供应链产品档案同步')).toBeInTheDocument()
|
||||||
expect(screen.getByText('同步商品、仓库和供应商扩展字段')).toBeInTheDocument()
|
expect(screen.getByText('同步商品、仓库和供应商扩展字段')).toBeInTheDocument()
|
||||||
expect(screen.getAllByText('仓库服务器 01')).toHaveLength(2)
|
expect(screen.getByText('仓库服务器 01')).toBeInTheDocument()
|
||||||
expect(screen.getByText('同步 182 条档案')).toBeInTheDocument()
|
expect(screen.getByText('同步 182 条档案')).toBeInTheDocument()
|
||||||
expect(screen.getByText('内网')).toBeInTheDocument()
|
expect(screen.getByText('在线 · 内网')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('最近成功')).toHaveLength(2)
|
||||||
|
expect(screen.getByRole('button', { name: '立即运行' })).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the empty state actionable for editors', () => {
|
it('keeps the empty state actionable for editors', () => {
|
||||||
@ -78,7 +86,7 @@ describe('ScriptMonitorView', () => {
|
|||||||
...overview,
|
...overview,
|
||||||
agents: [],
|
agents: [],
|
||||||
scripts: [],
|
scripts: [],
|
||||||
metrics: { agentTotal: 0, agentOnline: 0, scriptTotal: 0, healthy: 0, running: 0, abnormal: 0, unknown: 0 },
|
metrics: { agentTotal: 0, agentConnected: 0, agentOnline: 0, agentPending: 0, scriptTotal: 0, healthy: 0, running: 0, abnormal: 0, unknown: 0 },
|
||||||
}}
|
}}
|
||||||
canEdit
|
canEdit
|
||||||
databaseReady
|
databaseReady
|
||||||
@ -87,4 +95,41 @@ describe('ScriptMonitorView', () => {
|
|||||||
expect(screen.getByText('还没有接入机器')).toBeInTheDocument()
|
expect(screen.getByText('还没有接入机器')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: /接入第一台机器/ })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /接入第一台机器/ })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows a never-connected credential as pending without counting it as connected', () => {
|
||||||
|
render(<ScriptMonitorView
|
||||||
|
initialOverview={{
|
||||||
|
...overview,
|
||||||
|
agents: [{
|
||||||
|
...overview.agents[0],
|
||||||
|
id: 'agent-pending',
|
||||||
|
name: '测试机器',
|
||||||
|
hostname: null,
|
||||||
|
connectionKind: null,
|
||||||
|
connectionState: 'PENDING',
|
||||||
|
online: false,
|
||||||
|
lastSeenAt: null,
|
||||||
|
scriptCount: 0,
|
||||||
|
}],
|
||||||
|
scripts: [],
|
||||||
|
metrics: {
|
||||||
|
agentTotal: 1,
|
||||||
|
agentConnected: 0,
|
||||||
|
agentOnline: 0,
|
||||||
|
agentPending: 1,
|
||||||
|
scriptTotal: 0,
|
||||||
|
healthy: 0,
|
||||||
|
running: 0,
|
||||||
|
abnormal: 0,
|
||||||
|
unknown: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
canEdit
|
||||||
|
databaseReady
|
||||||
|
/>)
|
||||||
|
|
||||||
|
expect(screen.getAllByText('待接入')).toHaveLength(2)
|
||||||
|
expect(screen.getByText(/不计入已接入机器/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('共 0 台已接入')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,11 +3,12 @@
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Check,
|
Check,
|
||||||
CircleCheck,
|
Clock3,
|
||||||
Copy,
|
Copy,
|
||||||
Globe2,
|
Globe2,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
Network,
|
Network,
|
||||||
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
@ -35,12 +36,12 @@ type ApiEnvelope<T> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_META: Record<ScriptMonitorJobStatus, { label: string; className: string }> = {
|
const STATUS_META: Record<ScriptMonitorJobStatus, { label: string; className: string }> = {
|
||||||
RUNNING: { label: '运行中', className: 'monitor-status-running' },
|
RUNNING: { label: '正在运行', className: 'monitor-status-running' },
|
||||||
SUCCESS: { label: '正常', className: 'monitor-status-success' },
|
SUCCESS: { label: '最近成功', className: 'monitor-status-success' },
|
||||||
FAILED: { label: '失败', className: 'monitor-status-failed' },
|
FAILED: { label: '最近失败', className: 'monitor-status-failed' },
|
||||||
SILENT: { label: '超时未运行', className: 'monitor-status-silent' },
|
SILENT: { label: '超时未运行', className: 'monitor-status-silent' },
|
||||||
DISABLED: { label: '已停用', className: 'monitor-status-disabled' },
|
DISABLED: { label: '已停用', className: 'monitor-status-disabled' },
|
||||||
UNKNOWN: { label: '待确认', className: 'monitor-status-unknown' },
|
UNKNOWN: { label: '尚无结果', className: 'monitor-status-unknown' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const JOB_TYPE_LABELS = {
|
const JOB_TYPE_LABELS = {
|
||||||
@ -103,23 +104,55 @@ function StatusBadge({ status }: { status: ScriptMonitorJobStatus }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionBadge({ script }: { script: ScriptMonitorScriptRow }) {
|
function AgentConnectionBadge({
|
||||||
if (!script.agent.online) {
|
agent,
|
||||||
|
}: {
|
||||||
|
agent: ScriptMonitorOverview['agents'][number] | ScriptMonitorScriptRow['agent']
|
||||||
|
}) {
|
||||||
|
if (agent.connectionState === 'PENDING') {
|
||||||
return (
|
return (
|
||||||
<span className="monitor-connection monitor-connection-offline">
|
<span className="monitor-connection monitor-connection-pending">
|
||||||
<WifiOff size={12} aria-hidden="true" />离线
|
<Clock3 size={12} aria-hidden="true" />待接入
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const intranet = script.agent.connectionKind === 'INTRANET'
|
if (!agent.online) {
|
||||||
|
return (
|
||||||
|
<span className="monitor-connection monitor-connection-offline">
|
||||||
|
<WifiOff size={12} aria-hidden="true" />Agent 离线
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const intranet = agent.connectionKind === 'INTRANET'
|
||||||
return (
|
return (
|
||||||
<span className="monitor-connection">
|
<span className="monitor-connection">
|
||||||
{intranet ? <Network size={12} aria-hidden="true" /> : <Globe2 size={12} aria-hidden="true" />}
|
{intranet ? <Network size={12} aria-hidden="true" /> : <Globe2 size={12} aria-hidden="true" />}
|
||||||
{intranet ? '内网' : '公网'}
|
{intranet ? '在线 · 内网' : '在线 · 公网'}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function manualRunState(script: ScriptMonitorScriptRow, canEdit: boolean) {
|
||||||
|
if (!canEdit) return { disabled: true, label: '立即运行', reason: '没有手动运行权限' }
|
||||||
|
if (script.latestCommand?.status === 'PENDING') {
|
||||||
|
return { disabled: true, label: '等待 Agent', reason: '执行请求正在等待 Agent 领取' }
|
||||||
|
}
|
||||||
|
if (script.latestCommand?.status === 'RUNNING' || script.status === 'RUNNING') {
|
||||||
|
return { disabled: true, label: '执行中', reason: '脚本正在运行' }
|
||||||
|
}
|
||||||
|
if (!script.agent.online) {
|
||||||
|
return { disabled: true, label: '立即运行', reason: 'Agent 离线,无法手动运行' }
|
||||||
|
}
|
||||||
|
if (!script.canManualRun) {
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
label: '立即运行',
|
||||||
|
reason: 'Agent 配置未设置 allowManualRun: true',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { disabled: false, label: '立即运行', reason: '运行 Agent 本机预存命令' }
|
||||||
|
}
|
||||||
|
|
||||||
export function ScriptMonitorView({
|
export function ScriptMonitorView({
|
||||||
initialOverview,
|
initialOverview,
|
||||||
canEdit,
|
canEdit,
|
||||||
@ -138,7 +171,10 @@ export function ScriptMonitorView({
|
|||||||
const [runsLoading, setRunsLoading] = useState(false)
|
const [runsLoading, setRunsLoading] = useState(false)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [notice, setNotice] = useState<string | null>(null)
|
||||||
const [connectOpen, setConnectOpen] = useState(false)
|
const [connectOpen, setConnectOpen] = useState(false)
|
||||||
|
const [confirmRun, setConfirmRun] = useState<ScriptMonitorScriptRow | null>(null)
|
||||||
|
const [runRequesting, setRunRequesting] = useState(false)
|
||||||
|
|
||||||
const refresh = useCallback(async (quiet = false) => {
|
const refresh = useCallback(async (quiet = false) => {
|
||||||
if (!databaseReady) return
|
if (!databaseReady) return
|
||||||
@ -158,9 +194,13 @@ export function ScriptMonitorView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!databaseReady) return
|
if (!databaseReady) return
|
||||||
const timer = window.setInterval(() => refresh(true), 30_000)
|
const hasActiveCommand = overview.scripts.some((script) =>
|
||||||
|
script.latestCommand?.status === 'PENDING'
|
||||||
|
|| script.latestCommand?.status === 'RUNNING'
|
||||||
|
|| script.status === 'RUNNING')
|
||||||
|
const timer = window.setInterval(() => refresh(true), hasActiveCommand ? 5_000 : 30_000)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [databaseReady, refresh])
|
}, [databaseReady, overview.scripts, refresh])
|
||||||
|
|
||||||
const selectedScript = useMemo(
|
const selectedScript = useMemo(
|
||||||
() => overview.scripts.find((script) => script.id === selectedId) ?? null,
|
() => overview.scripts.find((script) => script.id === selectedId) ?? null,
|
||||||
@ -192,22 +232,47 @@ export function ScriptMonitorView({
|
|||||||
}
|
}
|
||||||
}, [selectedScript])
|
}, [selectedScript])
|
||||||
|
|
||||||
const filteredScripts = useMemo(() => {
|
const filteredAgentGroups = useMemo(() => {
|
||||||
const keyword = query.trim().toLocaleLowerCase('zh-CN')
|
const keyword = query.trim().toLocaleLowerCase('zh-CN')
|
||||||
return overview.scripts.filter((script) => {
|
return overview.agents.flatMap((agent) => {
|
||||||
if (statusFilter !== 'ALL' && script.status !== statusFilter) return false
|
if (agentFilter !== 'ALL' && agent.id !== agentFilter) return []
|
||||||
if (agentFilter !== 'ALL' && script.agent.id !== agentFilter) return false
|
const agentMatches = !keyword || [agent.name, agent.hostname, agent.os]
|
||||||
if (!keyword) return true
|
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword))
|
||||||
return [
|
const scripts = overview.scripts.filter((script) => {
|
||||||
script.name,
|
if (script.agent.id !== agent.id) return false
|
||||||
script.taskDescription,
|
if (statusFilter !== 'ALL' && script.status !== statusFilter) return false
|
||||||
script.agent.name,
|
if (agentMatches) return true
|
||||||
script.agent.hostname,
|
return [script.name, script.taskDescription, script.schedule, script.commandPreview]
|
||||||
script.schedule,
|
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword))
|
||||||
script.commandPreview,
|
})
|
||||||
].some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword))
|
const showEmptyAgent = statusFilter === 'ALL' && agentMatches
|
||||||
|
return scripts.length > 0 || showEmptyAgent ? [{ agent, scripts }] : []
|
||||||
})
|
})
|
||||||
}, [agentFilter, overview.scripts, query, statusFilter])
|
}, [agentFilter, overview.agents, overview.scripts, query, statusFilter])
|
||||||
|
|
||||||
|
const filteredScripts = useMemo(
|
||||||
|
() => filteredAgentGroups.flatMap((group) => group.scripts),
|
||||||
|
[filteredAgentGroups],
|
||||||
|
)
|
||||||
|
|
||||||
|
async function requestManualRun() {
|
||||||
|
if (!confirmRun) return
|
||||||
|
setRunRequesting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await readApi(await fetch(`/api/supply-chain/script-monitor/scripts/${confirmRun.id}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
}))
|
||||||
|
setNotice(`已发送“${confirmRun.name}”执行请求,等待 Agent 领取`)
|
||||||
|
setSelectedId(confirmRun.id)
|
||||||
|
setConfirmRun(null)
|
||||||
|
await refresh(true)
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '手动运行请求发送失败')
|
||||||
|
} finally {
|
||||||
|
setRunRequesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="monitor-page">
|
<div className="monitor-page">
|
||||||
@ -240,9 +305,9 @@ export function ScriptMonitorView({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="monitor-metrics" aria-label="脚本监控摘要">
|
<section className="monitor-metrics" aria-label="脚本监控摘要">
|
||||||
<div><Server size={16} /><span>机器</span><strong>{overview.metrics.agentOnline}/{overview.metrics.agentTotal}</strong><small>在线</small></div>
|
<div><Server size={16} /><span>在线机器</span><strong>{overview.metrics.agentOnline}</strong><small>共 {overview.metrics.agentConnected} 台已接入</small></div>
|
||||||
|
<div className={overview.metrics.agentPending > 0 ? 'is-pending' : ''}><Clock3 size={16} /><span>待接入</span><strong>{overview.metrics.agentPending}</strong><small>仅生成凭据,尚无心跳</small></div>
|
||||||
<div><Activity size={16} /><span>脚本</span><strong>{overview.metrics.scriptTotal}</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>
|
<div className={overview.metrics.abnormal > 0 ? 'is-alert' : ''}><TriangleAlert size={16} /><span>异常</span><strong>{overview.metrics.abnormal}</strong><small>失败或超时</small></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -259,6 +324,13 @@ export function ScriptMonitorView({
|
|||||||
<button type="button" onClick={() => setError(null)} aria-label="关闭提示"><X size={15} /></button>
|
<button type="button" onClick={() => setError(null)} aria-label="关闭提示"><X size={15} /></button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{notice ? (
|
||||||
|
<div className="monitor-notice" role="status">
|
||||||
|
<Check size={16} aria-hidden="true" />
|
||||||
|
<span>{notice}</span>
|
||||||
|
<button type="button" onClick={() => setNotice(null)} aria-label="关闭提示"><X size={15} /></button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="monitor-toolbar">
|
<div className="monitor-toolbar">
|
||||||
<label className="monitor-search">
|
<label className="monitor-search">
|
||||||
@ -272,9 +344,13 @@ export function ScriptMonitorView({
|
|||||||
</select>
|
</select>
|
||||||
<select value={agentFilter} onChange={(event) => setAgentFilter(event.target.value)} aria-label="按机器筛选">
|
<select value={agentFilter} onChange={(event) => setAgentFilter(event.target.value)} aria-label="按机器筛选">
|
||||||
<option value="ALL">全部机器</option>
|
<option value="ALL">全部机器</option>
|
||||||
{overview.agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
|
{overview.agents.map((agent) => (
|
||||||
|
<option key={agent.id} value={agent.id}>
|
||||||
|
{agent.name}{agent.connectionState === 'PENDING' ? '(待接入)' : agent.online ? '(在线)' : '(离线)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="monitor-result-count">{filteredScripts.length} 条</span>
|
<span className="monitor-result-count">{filteredAgentGroups.length} 台 · {filteredScripts.length} 个脚本</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className={`monitor-workspace ${selectedScript ? 'has-inspector' : ''}`}>
|
<main className={`monitor-workspace ${selectedScript ? 'has-inspector' : ''}`}>
|
||||||
@ -286,40 +362,84 @@ export function ScriptMonitorView({
|
|||||||
<p>为运行脚本的电脑生成独立 Agent 凭据,首次上报后会自动出现脚本清单。</p>
|
<p>为运行脚本的电脑生成独立 Agent 凭据,首次上报后会自动出现脚本清单。</p>
|
||||||
{canEdit && databaseReady ? <button type="button" className="monitor-button monitor-button-primary" onClick={() => setConnectOpen(true)}><Plus size={15} />接入第一台机器</button> : null}
|
{canEdit && databaseReady ? <button type="button" className="monitor-button monitor-button-primary" onClick={() => setConnectOpen(true)}><Plus size={15} />接入第一台机器</button> : null}
|
||||||
</section>
|
</section>
|
||||||
) : filteredScripts.length === 0 ? (
|
) : filteredAgentGroups.length === 0 ? (
|
||||||
<section className="monitor-empty">
|
<section className="monitor-empty">
|
||||||
<SquareTerminal size={32} strokeWidth={1.4} aria-hidden="true" />
|
<SquareTerminal size={32} strokeWidth={1.4} aria-hidden="true" />
|
||||||
<h2>{overview.scripts.length === 0 ? '等待 Agent 上报脚本' : '没有符合条件的脚本'}</h2>
|
<h2>没有符合条件的机器或脚本</h2>
|
||||||
<p>{overview.scripts.length === 0 ? 'Agent 在线后会定期扫描并上报 Cron、PM2 及配置任务。' : '调整搜索词、状态或机器筛选后重试。'}</p>
|
<p>调整搜索词、脚本状态或机器筛选后重试。</p>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<table className="monitor-table">
|
<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>
|
<thead><tr><th>最近状态</th><th>脚本与任务</th><th className="monitor-col-secondary">类型 / 计划</th><th className="monitor-col-secondary">最近运行</th><th>最近结果</th><th>操作</th></tr></thead>
|
||||||
<tbody>
|
{filteredAgentGroups.map(({ agent, scripts }) => (
|
||||||
{filteredScripts.map((script) => (
|
<tbody key={agent.id} className="monitor-agent-group">
|
||||||
<tr
|
<tr className="monitor-agent-row">
|
||||||
key={script.id}
|
<td colSpan={6}>
|
||||||
className={selectedId === script.id ? 'is-selected' : ''}
|
<div className="monitor-agent-summary">
|
||||||
tabIndex={0}
|
<div className="monitor-agent-identity">
|
||||||
role="button"
|
<AgentConnectionBadge agent={agent} />
|
||||||
aria-label={`查看 ${script.name} 详情`}
|
<strong>{agent.name}</strong>
|
||||||
onClick={() => setSelectedId(script.id)}
|
<span>{agent.hostname || (agent.connectionState === 'PENDING' ? '等待首次上报主机信息' : '未上报主机名')}</span>
|
||||||
onKeyDown={(event) => {
|
</div>
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
<div className="monitor-agent-meta">
|
||||||
event.preventDefault()
|
<span>{agent.scriptCount} 个脚本</span>
|
||||||
setSelectedId(script.id)
|
<span>{agent.lastSeenAt ? `最后心跳 ${formatDateTime(agent.lastSeenAt)}` : '凭据已生成,尚未连接'}</span>
|
||||||
}
|
</div>
|
||||||
}}
|
</div>
|
||||||
>
|
</td>
|
||||||
<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>
|
</tr>
|
||||||
))}
|
{scripts.length === 0 ? (
|
||||||
</tbody>
|
<tr className="monitor-agent-empty-row">
|
||||||
|
<td colSpan={6}>
|
||||||
|
{agent.connectionState === 'PENDING'
|
||||||
|
? '这台机器尚未成功接入,不计入已接入机器;Agent 首次上报后会显示脚本。'
|
||||||
|
: '这台机器尚未上报脚本,请检查 Agent 的扫描配置。'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : scripts.map((script) => {
|
||||||
|
const runState = manualRunState(script, canEdit)
|
||||||
|
return (
|
||||||
|
<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 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>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="monitor-run-button"
|
||||||
|
disabled={runState.disabled}
|
||||||
|
title={runState.reason}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setConfirmRun(script)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{runState.disabled && runState.label !== '立即运行'
|
||||||
|
? <LoaderCircle size={13} className={runState.label === '执行中' ? 'animate-spin' : ''} />
|
||||||
|
: <Play size={13} />}
|
||||||
|
{runState.label}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
))}
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -329,6 +449,8 @@ export function ScriptMonitorView({
|
|||||||
script={selectedScript}
|
script={selectedScript}
|
||||||
runs={runs}
|
runs={runs}
|
||||||
loading={runsLoading}
|
loading={runsLoading}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onRequestRun={() => setConfirmRun(selectedScript)}
|
||||||
onClose={() => setSelectedId(null)}
|
onClose={() => setSelectedId(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -340,6 +462,14 @@ export function ScriptMonitorView({
|
|||||||
onCreated={() => refresh(true)}
|
onCreated={() => refresh(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{confirmRun ? (
|
||||||
|
<ConfirmRunDialog
|
||||||
|
script={confirmRun}
|
||||||
|
submitting={runRequesting}
|
||||||
|
onCancel={() => setConfirmRun(null)}
|
||||||
|
onConfirm={requestManualRun}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.monitor-page { display:flex; min-height:0; flex:1; flex-direction:column; color:var(--sc-ink); background:var(--sc-bg); }
|
.monitor-page { display:flex; min-height:0; flex:1; flex-direction:column; color:var(--sc-ink); background:var(--sc-bg); }
|
||||||
@ -361,10 +491,13 @@ export function ScriptMonitorView({
|
|||||||
.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 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 small { grid-column:2; font-size:10px; color:var(--sc-subtle); }
|
||||||
.monitor-metrics .is-alert strong, .monitor-metrics .is-alert svg { color:#9f3a38; }
|
.monitor-metrics .is-alert strong, .monitor-metrics .is-alert svg { color:#9f3a38; }
|
||||||
|
.monitor-metrics .is-pending strong, .monitor-metrics .is-pending svg { color:#7b5b24; }
|
||||||
.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 { 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 div { display:flex; flex:1; flex-direction:column; gap:2px; }
|
||||||
.monitor-alert span { color:#7b625a; }
|
.monitor-alert span { color:#7b625a; }
|
||||||
.monitor-alert button { padding:3px; }
|
.monitor-alert button { padding:3px; }
|
||||||
|
.monitor-notice { display:flex; align-items:center; gap:8px; margin:12px 24px 0; border:1px solid #c6d9ce; border-radius:9px; padding:9px 12px; background:#f5faf7; color:#35624c; font-size:12px; }
|
||||||
|
.monitor-notice span { flex:1; }.monitor-notice 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-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 { 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 { min-width:0; flex:1; border:0; outline:0; background:transparent; color:var(--sc-ink); font-size:12px; }
|
||||||
@ -376,7 +509,7 @@ export function ScriptMonitorView({
|
|||||||
.monitor-table-wrap { min-width:0; overflow:auto; background:var(--sc-surface); scrollbar-color:#c6cbcd transparent; scrollbar-width:thin; }
|
.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 { 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 { 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 th:nth-child(1) { width:118px; }.monitor-table th:nth-child(2) { width:29%; }.monitor-table th:nth-child(3) { width:15%; }.monitor-table th:nth-child(4) { width:16%; }.monitor-table th:nth-child(5) { width:24%; }.monitor-table th:nth-child(6) { width:118px; }
|
||||||
.monitor-table td { height:66px; border-bottom:1px solid var(--sc-border); padding:9px 14px; vertical-align:middle; }
|
.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 { 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:hover, .monitor-table tbody tr:focus-visible { background:var(--sc-hover); }
|
||||||
@ -384,7 +517,17 @@ export function ScriptMonitorView({
|
|||||||
.monitor-table td strong { display:block; overflow:hidden; color:var(--sc-ink); font-weight:600; text-overflow:ellipsis; white-space:nowrap; }
|
.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-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-result-summary { max-width:100%; }
|
||||||
.monitor-agent-line { display:flex!important; align-items:center; gap:6px; }
|
.monitor-agent-row, .monitor-agent-empty-row { cursor:default!important; }
|
||||||
|
.monitor-agent-row:hover, .monitor-agent-empty-row:hover { background:inherit!important; }
|
||||||
|
.monitor-agent-row td { height:46px; padding:8px 14px; background:var(--sc-surface-muted); }
|
||||||
|
.monitor-agent-summary { display:flex; align-items:center; justify-content:space-between; gap:18px; }
|
||||||
|
.monitor-agent-identity, .monitor-agent-meta { display:flex; min-width:0; align-items:center; gap:9px; }
|
||||||
|
.monitor-agent-identity strong { display:block; max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
|
||||||
|
.monitor-agent-identity > span:not(.monitor-connection), .monitor-agent-meta span { color:var(--sc-muted); font-size:10px; }
|
||||||
|
.monitor-agent-meta { justify-content:flex-end; }.monitor-agent-meta span + span { padding-left:9px; border-left:1px solid var(--sc-border); }
|
||||||
|
.monitor-agent-empty-row td { height:54px; padding-left:34px; background:var(--sc-surface); color:var(--sc-muted); font-size:11px; }
|
||||||
|
.monitor-run-button { display:inline-flex; min-height:30px; align-items:center; justify-content:center; gap:5px; border:1px solid #2f3436; border-radius:7px; padding:0 9px; background:#2f3436; color:#fff; font-size:10px; font-weight:650; white-space:nowrap; }
|
||||||
|
.monitor-run-button:hover:not(:disabled) { background:#202426; }.monitor-run-button:disabled { border-color:var(--sc-border); background:var(--sc-surface-muted); color:var(--sc-subtle); cursor:not-allowed; }
|
||||||
: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) { 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-dot) { width:6px; height:6px; border-radius:50%; background:currentColor; }
|
||||||
:global(.monitor-status-success) { background:#edf4f0; color:#35624c; }
|
:global(.monitor-status-success) { background:#edf4f0; color:#35624c; }
|
||||||
@ -394,11 +537,12 @@ export function ScriptMonitorView({
|
|||||||
:global(.monitor-status-disabled), :global(.monitor-status-unknown) { background:#f2f3f3; color:#656d70; }
|
: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) { 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; }
|
:global(.monitor-connection-offline) { color:#8e4845; }
|
||||||
|
:global(.monitor-connection-pending) { color:#7b5b24; }
|
||||||
.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 { 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 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; }
|
.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: 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; } }
|
@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:720px; }.monitor-col-secondary { display:none; }.monitor-agent-summary { align-items:flex-start; flex-direction:column; gap:5px; }.monitor-agent-meta { justify-content:flex-start; } }
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -408,23 +552,34 @@ function ScriptInspector({
|
|||||||
script,
|
script,
|
||||||
runs,
|
runs,
|
||||||
loading,
|
loading,
|
||||||
|
canEdit,
|
||||||
|
onRequestRun,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
script: ScriptMonitorScriptRow
|
script: ScriptMonitorScriptRow
|
||||||
runs: ScriptMonitorLatestRun[]
|
runs: ScriptMonitorLatestRun[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
canEdit: boolean
|
||||||
|
onRequestRun: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const output = script.latestRun?.outputTail ?? script.lastOutputTail
|
const output = script.latestRun?.outputTail ?? script.lastOutputTail
|
||||||
const errorOutput = script.latestRun?.errorTail ?? script.lastErrorTail
|
const errorOutput = script.latestRun?.errorTail ?? script.lastErrorTail
|
||||||
|
const runState = manualRunState(script, canEdit)
|
||||||
return (
|
return (
|
||||||
<aside className="monitor-inspector" aria-label={`${script.name} 详情`}>
|
<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>
|
<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">
|
<div className="monitor-inspector-scroll">
|
||||||
<section><h3>任务</h3><p>{script.taskDescription || '暂未填写任务说明'}</p></section>
|
<section><h3>任务</h3><p>{script.taskDescription || '暂未填写任务说明'}</p></section>
|
||||||
|
<section className="monitor-manual-run-section">
|
||||||
|
<div><h3>手动运行</h3><p>{runState.reason}</p></div>
|
||||||
|
<button type="button" disabled={runState.disabled} onClick={onRequestRun}>
|
||||||
|
<Play size={14} />{runState.label}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>运行机器</dt><dd>{script.agent.name}<small>{script.agent.hostname || '未上报主机名'}</small></dd></div>
|
<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>Agent 状态</dt><dd>{script.agent.online ? (script.agent.connectionKind === 'INTRANET' ? '在线 · 内网连接' : '在线 · 公网连接') : 'Agent 离线'}<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>{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>
|
<div><dt>最近运行</dt><dd>{formatDateTime(script.lastFinishedAt ?? script.lastStartedAt)}<small>{formatDuration(script.lastDurationMs)} · {script.lastExitCode == null ? '无退出码' : `退出码 ${script.lastExitCode}`}</small></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
@ -441,6 +596,7 @@ function ScriptInspector({
|
|||||||
header button { border-radius:7px; padding:6px; color:var(--sc-muted); } header button:hover { background:var(--sc-hover); color:var(--sc-ink); }
|
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; }
|
.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; }
|
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; }
|
||||||
|
.monitor-manual-run-section { display:flex; align-items:center; justify-content:space-between; gap:14px; }.monitor-manual-run-section div { min-width:0; }.monitor-manual-run-section h3 { margin-bottom:4px; }.monitor-manual-run-section p { color:var(--sc-muted); font-size:10px; line-height:1.45; }.monitor-manual-run-section button { display:inline-flex; min-height:32px; flex:none; align-items:center; gap:6px; border:1px solid #2f3436; border-radius:7px; padding:0 10px; background:#2f3436; color:#fff; font-size:10px; font-weight:650; }.monitor-manual-run-section button:disabled { border-color:var(--sc-border); background:var(--sc-surface-muted); color:var(--sc-subtle); cursor:not-allowed; }
|
||||||
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; }
|
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; }
|
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; }
|
.is-error pre { border-color:#e5cbc9; background:#fff8f7; color:#7f3735; }
|
||||||
@ -454,6 +610,52 @@ function ScriptInspector({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConfirmRunDialog({
|
||||||
|
script,
|
||||||
|
submitting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
script: ScriptMonitorScriptRow
|
||||||
|
submitting: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="monitor-confirm-backdrop" role="presentation">
|
||||||
|
<section className="monitor-confirm" role="dialog" aria-modal="true" aria-labelledby="confirm-run-title">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h2 id="confirm-run-title">确认运行“{script.name}”</h2>
|
||||||
|
<p>执行请求将由 {script.agent.name} 的 Agent 主动领取。</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onCancel} aria-label="关闭"><X size={17} /></button>
|
||||||
|
</header>
|
||||||
|
<div className="monitor-confirm-body">
|
||||||
|
<p>平台不会下发任意命令,只会要求 Agent 执行本机配置中与此脚本对应的预存命令。</p>
|
||||||
|
{script.commandPreview ? <pre>{script.commandPreview}</pre> : null}
|
||||||
|
<footer>
|
||||||
|
<button type="button" onClick={onCancel} disabled={submitting}>取消</button>
|
||||||
|
<button type="button" className="is-primary" onClick={onConfirm} disabled={submitting}>
|
||||||
|
{submitting ? <LoaderCircle size={14} className="animate-spin" /> : <Play size={14} />}
|
||||||
|
{submitting ? '发送中' : '确认运行'}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<style jsx>{`
|
||||||
|
.monitor-confirm-backdrop { position:fixed; inset:0; z-index:85; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(28,32,34,.28); }
|
||||||
|
.monitor-confirm { width:min(500px,100%); 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:17px 18px; } header h2 { font-size:15px; 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); }
|
||||||
|
.monitor-confirm-body { display:flex; flex-direction:column; gap:13px; padding:18px; }.monitor-confirm-body > p { color:var(--sc-muted); font-size:11px; line-height:1.65; }
|
||||||
|
pre { max-height:120px; overflow:auto; border:1px solid var(--sc-border); border-radius:8px; padding:9px 10px; background:var(--sc-surface-muted); color:var(--sc-ink); font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:10px; white-space:pre-wrap; word-break:break-word; }
|
||||||
|
footer { display:flex; justify-content:flex-end; gap:8px; padding-top:3px; } footer button { display:inline-flex; min-height:36px; align-items:center; justify-content:center; gap:6px; border:1px solid var(--sc-border); border-radius:8px; padding:0 12px; font-size:11px; font-weight:650; } footer button.is-primary { border-color:#2f3436; background:#2f3436; color:#fff; } footer button:disabled { opacity:.55; }
|
||||||
|
@media (max-width:620px) { .monitor-confirm-backdrop { align-items:flex-end; padding:0; }.monitor-confirm { border-radius:14px 14px 0 0; } }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ConnectAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
function ConnectAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [networkMode, setNetworkMode] = useState<ScriptMonitorNetworkMode>('AUTO')
|
const [networkMode, setNetworkMode] = useState<ScriptMonitorNetworkMode>('AUTO')
|
||||||
@ -474,6 +676,7 @@ function ConnectAgentDialog({ onClose, onCreated }: { onClose: () => void; onCre
|
|||||||
internet: internetUrl || undefined,
|
internet: internetUrl || undefined,
|
||||||
},
|
},
|
||||||
reportIntervalSec: 60,
|
reportIntervalSec: 60,
|
||||||
|
commandPollIntervalSec: 5,
|
||||||
autoDiscover: { cron: true, pm2: true },
|
autoDiscover: { cron: true, pm2: true },
|
||||||
scripts: [],
|
scripts: [],
|
||||||
}, null, 2) : ''
|
}, null, 2) : ''
|
||||||
|
|||||||
@ -15,7 +15,9 @@ function emptyOverview(): ScriptMonitorOverview {
|
|||||||
scripts: [],
|
scripts: [],
|
||||||
metrics: {
|
metrics: {
|
||||||
agentTotal: 0,
|
agentTotal: 0,
|
||||||
|
agentConnected: 0,
|
||||||
agentOnline: 0,
|
agentOnline: 0,
|
||||||
|
agentPending: 0,
|
||||||
scriptTotal: 0,
|
scriptTotal: 0,
|
||||||
healthy: 0,
|
healthy: 0,
|
||||||
running: 0,
|
running: 0,
|
||||||
|
|||||||
@ -10,12 +10,21 @@ export const SCRIPT_MONITOR_JOB_STATUSES = [
|
|||||||
'DISABLED',
|
'DISABLED',
|
||||||
] as const
|
] as const
|
||||||
export const SCRIPT_MONITOR_RUN_STATUSES = ['RUNNING', 'SUCCESS', 'FAILED'] as const
|
export const SCRIPT_MONITOR_RUN_STATUSES = ['RUNNING', 'SUCCESS', 'FAILED'] as const
|
||||||
|
export const SCRIPT_MONITOR_COMMAND_STATUSES = [
|
||||||
|
'PENDING',
|
||||||
|
'RUNNING',
|
||||||
|
'SUCCESS',
|
||||||
|
'FAILED',
|
||||||
|
'EXPIRED',
|
||||||
|
] as const
|
||||||
|
|
||||||
export type ScriptMonitorNetworkMode = typeof SCRIPT_MONITOR_NETWORK_MODES[number]
|
export type ScriptMonitorNetworkMode = typeof SCRIPT_MONITOR_NETWORK_MODES[number]
|
||||||
export type ScriptMonitorConnectionKind = typeof SCRIPT_MONITOR_CONNECTION_KINDS[number]
|
export type ScriptMonitorConnectionKind = typeof SCRIPT_MONITOR_CONNECTION_KINDS[number]
|
||||||
export type ScriptMonitorJobType = typeof SCRIPT_MONITOR_JOB_TYPES[number]
|
export type ScriptMonitorJobType = typeof SCRIPT_MONITOR_JOB_TYPES[number]
|
||||||
export type ScriptMonitorJobStatus = typeof SCRIPT_MONITOR_JOB_STATUSES[number]
|
export type ScriptMonitorJobStatus = typeof SCRIPT_MONITOR_JOB_STATUSES[number]
|
||||||
export type ScriptMonitorRunStatus = typeof SCRIPT_MONITOR_RUN_STATUSES[number]
|
export type ScriptMonitorRunStatus = typeof SCRIPT_MONITOR_RUN_STATUSES[number]
|
||||||
|
export type ScriptMonitorCommandStatus = typeof SCRIPT_MONITOR_COMMAND_STATUSES[number]
|
||||||
|
export type ScriptMonitorAgentConnectionState = 'PENDING' | 'ONLINE' | 'OFFLINE'
|
||||||
|
|
||||||
export type ScriptMonitorLatestRun = {
|
export type ScriptMonitorLatestRun = {
|
||||||
id: string
|
id: string
|
||||||
@ -29,6 +38,14 @@ export type ScriptMonitorLatestRun = {
|
|||||||
outputTail: string | null
|
outputTail: string | null
|
||||||
errorTail: string | null
|
errorTail: string | null
|
||||||
}
|
}
|
||||||
|
export type ScriptMonitorLatestCommand = {
|
||||||
|
id: string
|
||||||
|
status: ScriptMonitorCommandStatus
|
||||||
|
requestedAt: string
|
||||||
|
claimedAt: string | null
|
||||||
|
finishedAt: string | null
|
||||||
|
errorMessage: string | null
|
||||||
|
}
|
||||||
export type ScriptMonitorScriptRow = {
|
export type ScriptMonitorScriptRow = {
|
||||||
id: string
|
id: string
|
||||||
externalKey: string
|
externalKey: string
|
||||||
@ -40,6 +57,7 @@ export type ScriptMonitorScriptRow = {
|
|||||||
expectedIntervalSec: number | null
|
expectedIntervalSec: number | null
|
||||||
status: ScriptMonitorJobStatus
|
status: ScriptMonitorJobStatus
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
|
canManualRun: boolean
|
||||||
lastStartedAt: string | null
|
lastStartedAt: string | null
|
||||||
lastFinishedAt: string | null
|
lastFinishedAt: string | null
|
||||||
lastDurationMs: number | null
|
lastDurationMs: number | null
|
||||||
@ -54,10 +72,12 @@ export type ScriptMonitorScriptRow = {
|
|||||||
hostname: string | null
|
hostname: string | null
|
||||||
networkMode: ScriptMonitorNetworkMode
|
networkMode: ScriptMonitorNetworkMode
|
||||||
connectionKind: ScriptMonitorConnectionKind | null
|
connectionKind: ScriptMonitorConnectionKind | null
|
||||||
|
connectionState: ScriptMonitorAgentConnectionState
|
||||||
online: boolean
|
online: boolean
|
||||||
lastSeenAt: string | null
|
lastSeenAt: string | null
|
||||||
}
|
}
|
||||||
latestRun: ScriptMonitorLatestRun | null
|
latestRun: ScriptMonitorLatestRun | null
|
||||||
|
latestCommand: ScriptMonitorLatestCommand | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScriptMonitorOverview = {
|
export type ScriptMonitorOverview = {
|
||||||
@ -68,6 +88,7 @@ export type ScriptMonitorOverview = {
|
|||||||
hostname: string | null
|
hostname: string | null
|
||||||
networkMode: ScriptMonitorNetworkMode
|
networkMode: ScriptMonitorNetworkMode
|
||||||
connectionKind: ScriptMonitorConnectionKind | null
|
connectionKind: ScriptMonitorConnectionKind | null
|
||||||
|
connectionState: ScriptMonitorAgentConnectionState
|
||||||
online: boolean
|
online: boolean
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
os: string | null
|
os: string | null
|
||||||
@ -80,7 +101,9 @@ export type ScriptMonitorOverview = {
|
|||||||
scripts: ScriptMonitorScriptRow[]
|
scripts: ScriptMonitorScriptRow[]
|
||||||
metrics: {
|
metrics: {
|
||||||
agentTotal: number
|
agentTotal: number
|
||||||
|
agentConnected: number
|
||||||
agentOnline: number
|
agentOnline: number
|
||||||
|
agentPending: number
|
||||||
scriptTotal: number
|
scriptTotal: number
|
||||||
healthy: number
|
healthy: number
|
||||||
running: number
|
running: number
|
||||||
@ -95,6 +118,14 @@ export type ScriptMonitorAgentCredentials = {
|
|||||||
tokenHint: string
|
tokenHint: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScriptMonitorClaimedCommand = {
|
||||||
|
id: string
|
||||||
|
scriptKey: string
|
||||||
|
scriptName: string
|
||||||
|
taskDescription: string
|
||||||
|
jobType: ScriptMonitorJobType
|
||||||
|
}
|
||||||
|
|
||||||
export function isScriptMonitorAgentOnline(input: {
|
export function isScriptMonitorAgentOnline(input: {
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
lastSeenAt: Date | string | null
|
lastSeenAt: Date | string | null
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export const scriptMonitorAgentReportSchema = z.object({
|
|||||||
expectedIntervalSec: z.number().int().min(15).max(31_536_000).nullable().optional(),
|
expectedIntervalSec: z.number().int().min(15).max(31_536_000).nullable().optional(),
|
||||||
status: z.enum(SCRIPT_MONITOR_JOB_STATUSES).default('UNKNOWN'),
|
status: z.enum(SCRIPT_MONITOR_JOB_STATUSES).default('UNKNOWN'),
|
||||||
isEnabled: z.boolean().default(true),
|
isEnabled: z.boolean().default(true),
|
||||||
|
canManualRun: z.boolean().default(false),
|
||||||
lastStartedAt: optionalDateTime,
|
lastStartedAt: optionalDateTime,
|
||||||
lastFinishedAt: optionalDateTime,
|
lastFinishedAt: optionalDateTime,
|
||||||
lastDurationMs: z.number().int().min(0).max(2_147_483_647).nullable().optional(),
|
lastDurationMs: z.number().int().min(0).max(2_147_483_647).nullable().optional(),
|
||||||
@ -42,6 +43,7 @@ export const scriptMonitorAgentReportSchema = z.object({
|
|||||||
}).strict()
|
}).strict()
|
||||||
|
|
||||||
export const scriptMonitorRunReportSchema = z.object({
|
export const scriptMonitorRunReportSchema = z.object({
|
||||||
|
commandId: optionalText(64),
|
||||||
scriptKey: z.string().trim().min(1).max(240),
|
scriptKey: z.string().trim().min(1).max(240),
|
||||||
scriptName: z.string().trim().min(1).max(180),
|
scriptName: z.string().trim().min(1).max(180),
|
||||||
taskDescription: z.string().trim().max(2000).default(''),
|
taskDescription: z.string().trim().max(2000).default(''),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Prisma,
|
Prisma,
|
||||||
|
ScriptMonitorCommandStatus,
|
||||||
ScriptMonitorConnectionKind,
|
ScriptMonitorConnectionKind,
|
||||||
ScriptMonitorJobStatus,
|
ScriptMonitorJobStatus,
|
||||||
ScriptMonitorJobType,
|
ScriptMonitorJobType,
|
||||||
@ -16,6 +17,10 @@ const overviewInclude = {
|
|||||||
orderBy: { startedAt: 'desc' },
|
orderBy: { startedAt: 'desc' },
|
||||||
take: 1,
|
take: 1,
|
||||||
},
|
},
|
||||||
|
commands: {
|
||||||
|
orderBy: { requestedAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies Prisma.ScriptMonitorAgentInclude
|
} satisfies Prisma.ScriptMonitorAgentInclude
|
||||||
@ -34,6 +39,7 @@ export type ScriptMonitorSnapshotRecord = {
|
|||||||
expectedIntervalSec?: number | null
|
expectedIntervalSec?: number | null
|
||||||
status: ScriptMonitorJobStatus
|
status: ScriptMonitorJobStatus
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
|
canManualRun: boolean
|
||||||
lastStartedAt?: Date | null
|
lastStartedAt?: Date | null
|
||||||
lastFinishedAt?: Date | null
|
lastFinishedAt?: Date | null
|
||||||
lastDurationMs?: number | null
|
lastDurationMs?: number | null
|
||||||
@ -67,6 +73,7 @@ export interface ScriptMonitorRepository {
|
|||||||
}): Promise<void>
|
}): Promise<void>
|
||||||
recordRun(input: {
|
recordRun(input: {
|
||||||
agentId: string
|
agentId: string
|
||||||
|
commandId?: string | null
|
||||||
scriptKey: string
|
scriptKey: string
|
||||||
scriptName: string
|
scriptName: string
|
||||||
taskDescription: string
|
taskDescription: string
|
||||||
@ -83,6 +90,35 @@ export interface ScriptMonitorRepository {
|
|||||||
errorTail?: string | null
|
errorTail?: string | null
|
||||||
reportedAt: Date
|
reportedAt: Date
|
||||||
}): Promise<void>
|
}): Promise<void>
|
||||||
|
findScriptForManualRun(id: string, now: Date): Promise<{
|
||||||
|
id: string
|
||||||
|
agentId: string
|
||||||
|
canManualRun: boolean
|
||||||
|
status: ScriptMonitorJobStatus
|
||||||
|
agent: {
|
||||||
|
id: string
|
||||||
|
isActive: boolean
|
||||||
|
lastSeenAt: Date | null
|
||||||
|
reportIntervalSec: number
|
||||||
|
}
|
||||||
|
commands: Array<{ id: string; status: ScriptMonitorCommandStatus }>
|
||||||
|
} | null>
|
||||||
|
createRunCommand(input: {
|
||||||
|
agentId: string
|
||||||
|
scriptId: string
|
||||||
|
requestedById: string
|
||||||
|
requestedByName: string
|
||||||
|
expiresAt: Date
|
||||||
|
}): Promise<{ id: string; status: ScriptMonitorCommandStatus; requestedAt: Date }>
|
||||||
|
claimPendingCommand(agentId: string, now: Date): Promise<{
|
||||||
|
id: string
|
||||||
|
script: {
|
||||||
|
externalKey: string
|
||||||
|
name: string
|
||||||
|
taskDescription: string
|
||||||
|
jobType: ScriptMonitorJobType
|
||||||
|
}
|
||||||
|
} | null>
|
||||||
listOverview(): Promise<ScriptMonitorAgentOverviewRecord[]>
|
listOverview(): Promise<ScriptMonitorAgentOverviewRecord[]>
|
||||||
listRuns(scriptId: string, take: number): Promise<Array<{
|
listRuns(scriptId: string, take: number): Promise<Array<{
|
||||||
id: string
|
id: string
|
||||||
@ -160,6 +196,7 @@ export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
|
|||||||
expectedIntervalSec: script.expectedIntervalSec ?? null,
|
expectedIntervalSec: script.expectedIntervalSec ?? null,
|
||||||
status: script.status,
|
status: script.status,
|
||||||
isEnabled: script.isEnabled,
|
isEnabled: script.isEnabled,
|
||||||
|
canManualRun: script.canManualRun,
|
||||||
lastStartedAt: script.lastStartedAt ?? null,
|
lastStartedAt: script.lastStartedAt ?? null,
|
||||||
lastFinishedAt: script.lastFinishedAt ?? null,
|
lastFinishedAt: script.lastFinishedAt ?? null,
|
||||||
lastDurationMs: script.lastDurationMs ?? null,
|
lastDurationMs: script.lastDurationMs ?? null,
|
||||||
@ -178,6 +215,7 @@ export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
|
|||||||
expectedIntervalSec: script.expectedIntervalSec ?? null,
|
expectedIntervalSec: script.expectedIntervalSec ?? null,
|
||||||
status: script.status,
|
status: script.status,
|
||||||
isEnabled: script.isEnabled,
|
isEnabled: script.isEnabled,
|
||||||
|
canManualRun: script.canManualRun,
|
||||||
lastStartedAt: script.lastStartedAt ?? undefined,
|
lastStartedAt: script.lastStartedAt ?? undefined,
|
||||||
lastFinishedAt: script.lastFinishedAt ?? undefined,
|
lastFinishedAt: script.lastFinishedAt ?? undefined,
|
||||||
lastDurationMs: script.lastDurationMs ?? undefined,
|
lastDurationMs: script.lastDurationMs ?? undefined,
|
||||||
@ -204,6 +242,7 @@ export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
|
|||||||
|
|
||||||
async recordRun(input: {
|
async recordRun(input: {
|
||||||
agentId: string
|
agentId: string
|
||||||
|
commandId?: string | null
|
||||||
scriptKey: string
|
scriptKey: string
|
||||||
scriptName: string
|
scriptName: string
|
||||||
taskDescription: string
|
taskDescription: string
|
||||||
@ -297,6 +336,120 @@ export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
|
|||||||
errorTail: input.errorTail ?? null,
|
errorTail: input.errorTail ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (input.commandId) {
|
||||||
|
const commandStatus: ScriptMonitorCommandStatus = input.status === 'RUNNING'
|
||||||
|
? 'RUNNING'
|
||||||
|
: input.status === 'SUCCESS'
|
||||||
|
? 'SUCCESS'
|
||||||
|
: 'FAILED'
|
||||||
|
await tx.scriptMonitorCommand.updateMany({
|
||||||
|
where: {
|
||||||
|
id: input.commandId,
|
||||||
|
agentId: input.agentId,
|
||||||
|
scriptId: script.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: commandStatus,
|
||||||
|
externalRunId: input.externalRunId,
|
||||||
|
...(input.status === 'RUNNING'
|
||||||
|
? { claimedAt: input.reportedAt }
|
||||||
|
: {
|
||||||
|
finishedAt: input.reportedAt,
|
||||||
|
errorMessage: input.status === 'FAILED'
|
||||||
|
? input.resultSummary ?? input.errorTail ?? '脚本执行失败'
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
findScriptForManualRun(id: string, now: Date) {
|
||||||
|
return prisma.scriptMonitorScript.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
agentId: true,
|
||||||
|
canManualRun: true,
|
||||||
|
status: true,
|
||||||
|
agent: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
isActive: true,
|
||||||
|
lastSeenAt: true,
|
||||||
|
reportIntervalSec: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ status: 'PENDING', expiresAt: { gt: now } },
|
||||||
|
{ status: 'RUNNING' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { requestedAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true, status: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createRunCommand(input: {
|
||||||
|
agentId: string
|
||||||
|
scriptId: string
|
||||||
|
requestedById: string
|
||||||
|
requestedByName: string
|
||||||
|
expiresAt: Date
|
||||||
|
}) {
|
||||||
|
return prisma.scriptMonitorCommand.create({
|
||||||
|
data: input,
|
||||||
|
select: { id: true, status: true, requestedAt: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimPendingCommand(agentId: string, now: Date) {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await tx.scriptMonitorCommand.updateMany({
|
||||||
|
where: { agentId, status: 'PENDING', expiresAt: { lte: now } },
|
||||||
|
data: {
|
||||||
|
status: 'EXPIRED',
|
||||||
|
finishedAt: now,
|
||||||
|
errorMessage: 'Agent 未在有效时间内领取执行请求',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const command = await tx.scriptMonitorCommand.findFirst({
|
||||||
|
where: {
|
||||||
|
agentId,
|
||||||
|
status: 'PENDING',
|
||||||
|
expiresAt: { gt: now },
|
||||||
|
script: { canManualRun: true },
|
||||||
|
},
|
||||||
|
orderBy: { requestedAt: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
script: {
|
||||||
|
select: {
|
||||||
|
externalKey: true,
|
||||||
|
name: true,
|
||||||
|
taskDescription: true,
|
||||||
|
jobType: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!command) return null
|
||||||
|
const claimed = await tx.scriptMonitorCommand.updateMany({
|
||||||
|
where: {
|
||||||
|
id: command.id,
|
||||||
|
status: 'PENDING',
|
||||||
|
expiresAt: { gt: now },
|
||||||
|
},
|
||||||
|
data: { status: 'RUNNING', claimedAt: now },
|
||||||
|
})
|
||||||
|
return claimed.count === 1 ? command : null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -157,9 +157,11 @@ export class StoreAccountManagementRepository {
|
|||||||
operationDepartmentHead?: string
|
operationDepartmentHead?: string
|
||||||
operationDirector?: string
|
operationDirector?: string
|
||||||
profitCenter?: string
|
profitCenter?: string
|
||||||
|
subject?: string
|
||||||
accountType: string
|
accountType: string
|
||||||
account: string
|
account: string
|
||||||
passwordCiphertext?: string
|
passwordCiphertext?: string
|
||||||
|
boundBankAccountNumberCiphertext?: string
|
||||||
boundPhone?: string
|
boundPhone?: string
|
||||||
verificationContact?: string
|
verificationContact?: string
|
||||||
jstOrderService?: string
|
jstOrderService?: string
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-manage
|
|||||||
|
|
||||||
jest.mock('@/lib/store-account-crypto', () => ({
|
jest.mock('@/lib/store-account-crypto', () => ({
|
||||||
decryptStoreAccountPassword: (value: string) => value ? `明文:${value}` : '',
|
decryptStoreAccountPassword: (value: string) => value ? `明文:${value}` : '',
|
||||||
|
decryptStoreAccountBankAccount: (value: string) => value ? `银行卡:${value}` : '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const adminUser = {
|
const adminUser = {
|
||||||
@ -23,9 +24,11 @@ const record = {
|
|||||||
operationDepartmentHead: '部门负责人A',
|
operationDepartmentHead: '部门负责人A',
|
||||||
operationDirector: '总监A',
|
operationDirector: '总监A',
|
||||||
profitCenter: '利润中心A',
|
profitCenter: '利润中心A',
|
||||||
|
subject: '测试主体',
|
||||||
accountType: 'MAIN',
|
accountType: 'MAIN',
|
||||||
account: 'shop-account',
|
account: 'shop-account',
|
||||||
passwordCiphertext: 'ciphertext-1',
|
passwordCiphertext: 'ciphertext-1',
|
||||||
|
boundBankAccountNumberCiphertext: 'bank-ciphertext-1',
|
||||||
boundPhone: '13800000000',
|
boundPhone: '13800000000',
|
||||||
verificationContact: '对接人A',
|
verificationContact: '对接人A',
|
||||||
jstOrderService: 'JST-001',
|
jstOrderService: 'JST-001',
|
||||||
@ -57,9 +60,11 @@ describe('StoreAccountManagementExportService', () => {
|
|||||||
'运营部门负责人',
|
'运营部门负责人',
|
||||||
'运营总监',
|
'运营总监',
|
||||||
'利润中心',
|
'利润中心',
|
||||||
|
'主体',
|
||||||
'账号类型',
|
'账号类型',
|
||||||
'账号',
|
'账号',
|
||||||
'密码',
|
'密码',
|
||||||
|
'店铺绑定银行卡号',
|
||||||
'绑定手机号',
|
'绑定手机号',
|
||||||
'手机号验证码对接人',
|
'手机号验证码对接人',
|
||||||
'聚水潭订单服务号',
|
'聚水潭订单服务号',
|
||||||
@ -67,8 +72,9 @@ describe('StoreAccountManagementExportService', () => {
|
|||||||
'创建时间',
|
'创建时间',
|
||||||
'更新时间',
|
'更新时间',
|
||||||
])
|
])
|
||||||
expect(worksheet.getCell('F2').value).toBe('主账号')
|
expect(worksheet.getCell('G2').value).toBe('主账号')
|
||||||
expect(worksheet.getCell('H2').value).toBe('明文:ciphertext-1')
|
expect(worksheet.getCell('I2').value).toBe('明文:ciphertext-1')
|
||||||
|
expect(worksheet.getCell('J2').value).toBe('银行卡:bank-ciphertext-1')
|
||||||
expect(payload.count).toBe(1)
|
expect(payload.count).toBe(1)
|
||||||
expect(payload.fileName).toContain('店铺账号管理-全部-')
|
expect(payload.fileName).toContain('店铺账号管理-全部-')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -322,3 +322,99 @@ describe('StoreAccountManagementService import', () => {
|
|||||||
})).rejects.toThrow('账号类型只能是主账号或子账号')
|
})).rejects.toThrow('账号类型只能是主账号或子账号')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('StoreAccountManagementService store profiles', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: 'admin-1',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
name: '管理员',
|
||||||
|
role: 'ADMIN' as const,
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('saves a profile only when the store prefix matches the platform', async () => {
|
||||||
|
const listProfiles = jest.fn().mockResolvedValue([])
|
||||||
|
const upsertProfile = jest.fn().mockResolvedValue({
|
||||||
|
id: 'profile-1',
|
||||||
|
storeName: '京东-测试店',
|
||||||
|
platform: 'JD',
|
||||||
|
merchantNumber: 'merchant-1',
|
||||||
|
secondaryMerchantNumber: '',
|
||||||
|
merchantStoreId: '',
|
||||||
|
companyId: '',
|
||||||
|
createdAt: new Date('2026-08-28T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-28T00:00:00.000Z'),
|
||||||
|
})
|
||||||
|
const createLog = jest.fn().mockResolvedValue(undefined)
|
||||||
|
const service = new StoreAccountManagementService({
|
||||||
|
listProfiles,
|
||||||
|
upsertProfile,
|
||||||
|
createLog,
|
||||||
|
} as unknown as StoreAccountManagementRepository)
|
||||||
|
jest.spyOn(service, 'getFieldAccess').mockResolvedValue({
|
||||||
|
target: { view: true, edit: true, download: true },
|
||||||
|
access: {} as StoreAccountFieldAccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(service.saveStoreProfile({
|
||||||
|
storeName: '京喜-测试店',
|
||||||
|
platform: 'JD',
|
||||||
|
values: {},
|
||||||
|
user,
|
||||||
|
})).rejects.toThrow('店铺资料平台必须与京东-或京喜-店铺名称一致')
|
||||||
|
|
||||||
|
await expect(service.saveStoreProfile({
|
||||||
|
storeName: '京东-测试店',
|
||||||
|
platform: 'JD',
|
||||||
|
values: { merchantNumber: 'merchant-1' },
|
||||||
|
user,
|
||||||
|
})).resolves.toEqual(expect.objectContaining({
|
||||||
|
storeName: '京东-测试店',
|
||||||
|
platform: 'JD',
|
||||||
|
merchantNumber: 'merchant-1',
|
||||||
|
}))
|
||||||
|
expect(upsertProfile).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
storeName: '京东-测试店',
|
||||||
|
platform: 'JD',
|
||||||
|
actorId: 'admin-1',
|
||||||
|
}))
|
||||||
|
expect(createLog).toHaveBeenCalledWith(expect.objectContaining({ operation: 'PROFILE_UPDATE' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks edit permission once for a multi-row profile import', async () => {
|
||||||
|
const listProfiles = jest.fn().mockResolvedValue([])
|
||||||
|
const upsertProfile = jest.fn().mockImplementation(async ({ storeName, platform, values }) => ({
|
||||||
|
id: `${platform}-${storeName}`,
|
||||||
|
storeName,
|
||||||
|
platform,
|
||||||
|
...values,
|
||||||
|
createdAt: new Date('2026-08-28T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-28T00:00:00.000Z'),
|
||||||
|
}))
|
||||||
|
const createLog = jest.fn().mockResolvedValue(undefined)
|
||||||
|
const service = new StoreAccountManagementService({
|
||||||
|
listProfiles,
|
||||||
|
upsertProfile,
|
||||||
|
createLog,
|
||||||
|
} as unknown as StoreAccountManagementRepository)
|
||||||
|
const getFieldAccess = jest.spyOn(service, 'getFieldAccess').mockResolvedValue({
|
||||||
|
target: { view: true, edit: true, download: true },
|
||||||
|
access: {} as StoreAccountFieldAccess,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(service.importStoreProfiles({
|
||||||
|
platform: 'JX',
|
||||||
|
rows: [
|
||||||
|
{ storeName: '京喜-一店', companyId: 'company-1' },
|
||||||
|
{ storeName: '京喜-二店', companyId: 'company-2' },
|
||||||
|
],
|
||||||
|
user,
|
||||||
|
})).resolves.toEqual({ processed: 2 })
|
||||||
|
expect(getFieldAccess).toHaveBeenCalledTimes(1)
|
||||||
|
expect(upsertProfile).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -12,6 +12,9 @@ function repositoryMock(): jest.Mocked<ScriptMonitorRepository> {
|
|||||||
findAgentAuthentication: jest.fn(),
|
findAgentAuthentication: jest.fn(),
|
||||||
applySnapshot: jest.fn(),
|
applySnapshot: jest.fn(),
|
||||||
recordRun: jest.fn(),
|
recordRun: jest.fn(),
|
||||||
|
findScriptForManualRun: jest.fn(),
|
||||||
|
createRunCommand: jest.fn(),
|
||||||
|
claimPendingCommand: jest.fn(),
|
||||||
listOverview: jest.fn(),
|
listOverview: jest.fn(),
|
||||||
listRuns: jest.fn(),
|
listRuns: jest.fn(),
|
||||||
}
|
}
|
||||||
@ -60,6 +63,7 @@ describe('ScriptMonitorService', () => {
|
|||||||
expectedIntervalSec: 900,
|
expectedIntervalSec: 900,
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
|
canManualRun: false,
|
||||||
lastFinishedAt: '2026-08-28T11:45:12.000Z',
|
lastFinishedAt: '2026-08-28T11:45:12.000Z',
|
||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
@ -90,4 +94,70 @@ describe('ScriptMonitorService', () => {
|
|||||||
})).rejects.toMatchObject({ code: 'AUTH_002' })
|
})).rejects.toMatchObject({ code: 'AUTH_002' })
|
||||||
expect(repository.applySnapshot).not.toHaveBeenCalled()
|
expect(repository.applySnapshot).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not count credentials without a first heartbeat as connected machines', async () => {
|
||||||
|
const repository = repositoryMock()
|
||||||
|
repository.listOverview.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'agent-pending',
|
||||||
|
name: '待接入机器',
|
||||||
|
hostname: null,
|
||||||
|
networkMode: 'AUTO',
|
||||||
|
lastConnectionKind: null,
|
||||||
|
tokenHash: tokenHash('pending'),
|
||||||
|
tokenHint: 'ending',
|
||||||
|
isActive: true,
|
||||||
|
os: null,
|
||||||
|
agentVersion: null,
|
||||||
|
reportIntervalSec: 60,
|
||||||
|
lastSeenAt: null,
|
||||||
|
createdAt: new Date('2026-08-28T12:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-28T12:00:00.000Z'),
|
||||||
|
scripts: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const service = new ScriptMonitorService(repository)
|
||||||
|
|
||||||
|
const result = await service.getOverview()
|
||||||
|
|
||||||
|
expect(result.metrics).toEqual(expect.objectContaining({
|
||||||
|
agentTotal: 1,
|
||||||
|
agentConnected: 0,
|
||||||
|
agentOnline: 0,
|
||||||
|
agentPending: 1,
|
||||||
|
}))
|
||||||
|
expect(result.agents[0].connectionState).toBe('PENDING')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues a manual run only for an online explicitly enabled script', async () => {
|
||||||
|
const repository = repositoryMock()
|
||||||
|
repository.findScriptForManualRun.mockResolvedValue({
|
||||||
|
id: 'script-1',
|
||||||
|
agentId: 'agent-1',
|
||||||
|
canManualRun: true,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
agent: {
|
||||||
|
id: 'agent-1',
|
||||||
|
isActive: true,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
reportIntervalSec: 60,
|
||||||
|
},
|
||||||
|
commands: [],
|
||||||
|
})
|
||||||
|
repository.createRunCommand.mockResolvedValue({
|
||||||
|
id: 'command-1',
|
||||||
|
status: 'PENDING',
|
||||||
|
requestedAt: new Date('2026-08-28T12:00:00.000Z'),
|
||||||
|
})
|
||||||
|
const service = new ScriptMonitorService(repository)
|
||||||
|
|
||||||
|
const result = await service.requestManualRun('script-1', { id: 'user-1', name: '测试用户' })
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ id: 'command-1', status: 'PENDING' }))
|
||||||
|
expect(repository.createRunCommand).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
agentId: 'agent-1',
|
||||||
|
scriptId: 'script-1',
|
||||||
|
requestedById: 'user-1',
|
||||||
|
}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
|
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
|
||||||
import type {
|
import type {
|
||||||
ScriptMonitorAgentCredentials,
|
ScriptMonitorAgentCredentials,
|
||||||
|
ScriptMonitorAgentConnectionState,
|
||||||
ScriptMonitorOverview,
|
ScriptMonitorOverview,
|
||||||
} from '@/lib/supply-chain-script-monitor'
|
} from '@/lib/supply-chain-script-monitor'
|
||||||
import {
|
import {
|
||||||
@ -45,6 +46,22 @@ function serializeRun(run: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeCommand(command: {
|
||||||
|
id: string
|
||||||
|
status: 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | 'EXPIRED'
|
||||||
|
requestedAt: Date
|
||||||
|
claimedAt: Date | null
|
||||||
|
finishedAt: Date | null
|
||||||
|
errorMessage: string | null
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
...command,
|
||||||
|
requestedAt: command.requestedAt.toISOString(),
|
||||||
|
claimedAt: command.claimedAt?.toISOString() ?? null,
|
||||||
|
finishedAt: command.finishedAt?.toISOString() ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ScriptMonitorService {
|
export class ScriptMonitorService {
|
||||||
constructor(private readonly repository: ScriptMonitorRepository = scriptMonitorRepository) {}
|
constructor(private readonly repository: ScriptMonitorRepository = scriptMonitorRepository) {}
|
||||||
|
|
||||||
@ -106,6 +123,7 @@ export class ScriptMonitorService {
|
|||||||
const reportedAt = new Date()
|
const reportedAt = new Date()
|
||||||
await this.repository.recordRun({
|
await this.repository.recordRun({
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
|
commandId: input.commandId,
|
||||||
scriptKey: input.scriptKey,
|
scriptKey: input.scriptKey,
|
||||||
scriptName: input.scriptName,
|
scriptName: input.scriptName,
|
||||||
taskDescription: input.taskDescription,
|
taskDescription: input.taskDescription,
|
||||||
@ -125,11 +143,71 @@ export class ScriptMonitorService {
|
|||||||
return { ok: true, reportedAt: reportedAt.toISOString() }
|
return { ok: true, reportedAt: reportedAt.toISOString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async claimCommand(agentId: string, token: string) {
|
||||||
|
const agent = await this.authenticateAgent(agentId, token)
|
||||||
|
const command = await this.repository.claimPendingCommand(agent.id, new Date())
|
||||||
|
return {
|
||||||
|
command: command ? {
|
||||||
|
id: command.id,
|
||||||
|
scriptKey: command.script.externalKey,
|
||||||
|
scriptName: command.script.name,
|
||||||
|
taskDescription: command.script.taskDescription,
|
||||||
|
jobType: command.script.jobType,
|
||||||
|
} : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestManualRun(
|
||||||
|
scriptId: string,
|
||||||
|
actor: { id: string; name: string },
|
||||||
|
) {
|
||||||
|
const now = new Date()
|
||||||
|
const script = await this.repository.findScriptForManualRun(scriptId, now)
|
||||||
|
if (!script) {
|
||||||
|
throw new AppError(ErrorCode.VALIDATION_FAILED, '脚本不存在或已移除')
|
||||||
|
}
|
||||||
|
if (!script.canManualRun) {
|
||||||
|
throw new AppError(
|
||||||
|
ErrorCode.VALIDATION_FAILED,
|
||||||
|
'该脚本未开放手动运行,请在 Agent 配置中设置 allowManualRun: true',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!isScriptMonitorAgentOnline(script.agent, now.getTime())) {
|
||||||
|
throw new AppError(
|
||||||
|
ErrorCode.VALIDATION_FAILED,
|
||||||
|
'Agent 当前离线,无法立即运行脚本',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (script.status === 'RUNNING' || script.commands.length > 0) {
|
||||||
|
throw new AppError(
|
||||||
|
ErrorCode.VALIDATION_FAILED,
|
||||||
|
'该脚本已有执行请求,请等待当前任务结束',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const command = await this.repository.createRunCommand({
|
||||||
|
agentId: script.agentId,
|
||||||
|
scriptId: script.id,
|
||||||
|
requestedById: actor.id,
|
||||||
|
requestedByName: actor.name,
|
||||||
|
expiresAt: new Date(now.getTime() + 5 * 60 * 1000),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
id: command.id,
|
||||||
|
status: command.status,
|
||||||
|
requestedAt: command.requestedAt.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getOverview(): Promise<ScriptMonitorOverview> {
|
async getOverview(): Promise<ScriptMonitorOverview> {
|
||||||
const records = await this.repository.listOverview()
|
const records = await this.repository.listOverview()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const agents = records.map((agent) => {
|
const agents = records.map((agent) => {
|
||||||
const online = isScriptMonitorAgentOnline(agent, now)
|
const online = isScriptMonitorAgentOnline(agent, now)
|
||||||
|
const connectionState: ScriptMonitorAgentConnectionState = !agent.lastSeenAt
|
||||||
|
? 'PENDING'
|
||||||
|
: online
|
||||||
|
? 'ONLINE'
|
||||||
|
: 'OFFLINE'
|
||||||
const abnormalCount = agent.scripts.filter((script) => {
|
const abnormalCount = agent.scripts.filter((script) => {
|
||||||
const status = resolveScriptMonitorEffectiveStatus(script, now)
|
const status = resolveScriptMonitorEffectiveStatus(script, now)
|
||||||
return status === 'FAILED' || status === 'SILENT'
|
return status === 'FAILED' || status === 'SILENT'
|
||||||
@ -140,6 +218,7 @@ export class ScriptMonitorService {
|
|||||||
hostname: agent.hostname,
|
hostname: agent.hostname,
|
||||||
networkMode: agent.networkMode,
|
networkMode: agent.networkMode,
|
||||||
connectionKind: agent.lastConnectionKind,
|
connectionKind: agent.lastConnectionKind,
|
||||||
|
connectionState,
|
||||||
online,
|
online,
|
||||||
isActive: agent.isActive,
|
isActive: agent.isActive,
|
||||||
os: agent.os,
|
os: agent.os,
|
||||||
@ -165,6 +244,7 @@ export class ScriptMonitorService {
|
|||||||
expectedIntervalSec: script.expectedIntervalSec,
|
expectedIntervalSec: script.expectedIntervalSec,
|
||||||
status: resolveScriptMonitorEffectiveStatus(script, now),
|
status: resolveScriptMonitorEffectiveStatus(script, now),
|
||||||
isEnabled: script.isEnabled,
|
isEnabled: script.isEnabled,
|
||||||
|
canManualRun: script.canManualRun,
|
||||||
lastStartedAt: script.lastStartedAt?.toISOString() ?? null,
|
lastStartedAt: script.lastStartedAt?.toISOString() ?? null,
|
||||||
lastFinishedAt: script.lastFinishedAt?.toISOString() ?? null,
|
lastFinishedAt: script.lastFinishedAt?.toISOString() ?? null,
|
||||||
lastDurationMs: script.lastDurationMs,
|
lastDurationMs: script.lastDurationMs,
|
||||||
@ -179,10 +259,12 @@ export class ScriptMonitorService {
|
|||||||
hostname: resolvedAgent.hostname,
|
hostname: resolvedAgent.hostname,
|
||||||
networkMode: resolvedAgent.networkMode,
|
networkMode: resolvedAgent.networkMode,
|
||||||
connectionKind: resolvedAgent.connectionKind,
|
connectionKind: resolvedAgent.connectionKind,
|
||||||
|
connectionState: resolvedAgent.connectionState,
|
||||||
online: resolvedAgent.online,
|
online: resolvedAgent.online,
|
||||||
lastSeenAt: resolvedAgent.lastSeenAt,
|
lastSeenAt: resolvedAgent.lastSeenAt,
|
||||||
},
|
},
|
||||||
latestRun: latestRun ? serializeRun(latestRun) : null,
|
latestRun: latestRun ? serializeRun(latestRun) : null,
|
||||||
|
latestCommand: script.commands[0] ? serializeCommand(script.commands[0]) : null,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
const healthy = scripts.filter((script) => script.status === 'SUCCESS').length
|
const healthy = scripts.filter((script) => script.status === 'SUCCESS').length
|
||||||
@ -196,7 +278,9 @@ export class ScriptMonitorService {
|
|||||||
scripts,
|
scripts,
|
||||||
metrics: {
|
metrics: {
|
||||||
agentTotal: agents.length,
|
agentTotal: agents.length,
|
||||||
|
agentConnected: agents.filter((agent) => agent.connectionState !== 'PENDING').length,
|
||||||
agentOnline: agents.filter((agent) => agent.online).length,
|
agentOnline: agents.filter((agent) => agent.online).length,
|
||||||
|
agentPending: agents.filter((agent) => agent.connectionState === 'PENDING').length,
|
||||||
scriptTotal: scripts.length,
|
scriptTotal: scripts.length,
|
||||||
healthy,
|
healthy,
|
||||||
running,
|
running,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import ExcelJS from 'exceljs'
|
import ExcelJS from 'exceljs'
|
||||||
import type { AuthUser } from '@/server/core/types'
|
import type { AuthUser } from '@/server/core/types'
|
||||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||||
import { decryptStoreAccountPassword } from '@/lib/store-account-crypto'
|
import { decryptStoreAccountBankAccount, decryptStoreAccountPassword } from '@/lib/store-account-crypto'
|
||||||
import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-management'
|
import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-management'
|
||||||
import {
|
import {
|
||||||
storeAccountManagementRepository,
|
storeAccountManagementRepository,
|
||||||
@ -18,9 +18,11 @@ const EXPORT_COLUMNS = [
|
|||||||
{ key: 'operationDepartmentHead', header: '运营部门负责人', width: 18 },
|
{ key: 'operationDepartmentHead', header: '运营部门负责人', width: 18 },
|
||||||
{ key: 'operationDirector', header: '运营总监', width: 16 },
|
{ key: 'operationDirector', header: '运营总监', width: 16 },
|
||||||
{ key: 'profitCenter', header: '利润中心', width: 16 },
|
{ key: 'profitCenter', header: '利润中心', width: 16 },
|
||||||
|
{ key: 'subject', header: '主体', width: 20 },
|
||||||
{ key: 'accountType', header: '账号类型', width: 12 },
|
{ key: 'accountType', header: '账号类型', width: 12 },
|
||||||
{ key: 'account', header: '账号', width: 24 },
|
{ key: 'account', header: '账号', width: 24 },
|
||||||
{ key: 'password', header: '密码', width: 24 },
|
{ key: 'password', header: '密码', width: 24 },
|
||||||
|
{ key: 'boundBankAccountNumber', header: '店铺绑定银行卡号', width: 26 },
|
||||||
{ key: 'boundPhone', header: '绑定手机号', width: 18 },
|
{ key: 'boundPhone', header: '绑定手机号', width: 18 },
|
||||||
{ key: 'verificationContact', header: '手机号验证码对接人', width: 22 },
|
{ key: 'verificationContact', header: '手机号验证码对接人', width: 22 },
|
||||||
{ key: 'jstOrderService', header: '聚水潭订单服务号', width: 22 },
|
{ key: 'jstOrderService', header: '聚水潭订单服务号', width: 22 },
|
||||||
@ -82,7 +84,7 @@ export class StoreAccountManagementExportService {
|
|||||||
width: column.width,
|
width: column.width,
|
||||||
}))
|
}))
|
||||||
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
|
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
|
||||||
worksheet.autoFilter = { from: 'A1', to: 'N1' }
|
worksheet.autoFilter = { from: 'A1', to: 'P1' }
|
||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
worksheet.addRow({
|
worksheet.addRow({
|
||||||
@ -91,9 +93,11 @@ export class StoreAccountManagementExportService {
|
|||||||
operationDepartmentHead: row.operationDepartmentHead,
|
operationDepartmentHead: row.operationDepartmentHead,
|
||||||
operationDirector: row.operationDirector,
|
operationDirector: row.operationDirector,
|
||||||
profitCenter: row.profitCenter,
|
profitCenter: row.profitCenter,
|
||||||
|
subject: row.subject,
|
||||||
accountType: formatAccountType(row.accountType),
|
accountType: formatAccountType(row.accountType),
|
||||||
account: row.account,
|
account: row.account,
|
||||||
password: decryptStoreAccountPassword(row.passwordCiphertext),
|
password: decryptStoreAccountPassword(row.passwordCiphertext),
|
||||||
|
boundBankAccountNumber: decryptStoreAccountBankAccount(row.boundBankAccountNumberCiphertext),
|
||||||
boundPhone: row.boundPhone,
|
boundPhone: row.boundPhone,
|
||||||
verificationContact: row.verificationContact,
|
verificationContact: row.verificationContact,
|
||||||
jstOrderService: row.jstOrderService,
|
jstOrderService: row.jstOrderService,
|
||||||
|
|||||||
@ -578,6 +578,48 @@ export class StoreAccountManagementService {
|
|||||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||||
"当前账号没有编辑店铺资料的权限",
|
"当前账号没有编辑店铺资料的权限",
|
||||||
);
|
);
|
||||||
|
return this.persistStoreProfile(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async importStoreProfiles(input: {
|
||||||
|
platform: unknown;
|
||||||
|
rows: Array<Record<string, unknown>>;
|
||||||
|
user: AuthUser;
|
||||||
|
}) {
|
||||||
|
const { target } = await this.getFieldAccess(input.user);
|
||||||
|
if (!target.edit)
|
||||||
|
throw new AppError(
|
||||||
|
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||||
|
"当前账号没有编辑店铺资料的权限",
|
||||||
|
);
|
||||||
|
if (input.rows.length === 0)
|
||||||
|
throw new AppError(
|
||||||
|
ErrorCode.VALIDATION_REQUIRED_FIELD,
|
||||||
|
"导入文件没有可用数据",
|
||||||
|
);
|
||||||
|
if (input.rows.length > 5000)
|
||||||
|
throw new AppError(
|
||||||
|
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||||
|
"单次最多导入5000行",
|
||||||
|
);
|
||||||
|
const platform = normalizeProfilePlatform(input.platform);
|
||||||
|
for (const row of input.rows) {
|
||||||
|
await this.persistStoreProfile({
|
||||||
|
storeName: clean(row.storeName, 240),
|
||||||
|
platform,
|
||||||
|
values: row,
|
||||||
|
user: input.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { processed: input.rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistStoreProfile(input: {
|
||||||
|
storeName: string;
|
||||||
|
platform: unknown;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
user: AuthUser;
|
||||||
|
}) {
|
||||||
const storeName = clean(input.storeName, 240);
|
const storeName = clean(input.storeName, 240);
|
||||||
if (!storeName)
|
if (!storeName)
|
||||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少店铺名称");
|
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少店铺名称");
|
||||||
@ -598,7 +640,7 @@ export class StoreAccountManagementService {
|
|||||||
field.key,
|
field.key,
|
||||||
clean(input.values[field.key], 500),
|
clean(input.values[field.key], 500),
|
||||||
]),
|
]),
|
||||||
);
|
) as Record<(typeof STORE_ACCOUNT_PROFILE_FIELDS)[number]["key"], string>;
|
||||||
const next = await this.repository.upsertProfile({
|
const next = await this.repository.upsertProfile({
|
||||||
storeName,
|
storeName,
|
||||||
platform,
|
platform,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user