merge: release script monitor machine deletion

This commit is contained in:
WuYongJie 2026-08-28 20:48:38 +08:00
commit dc16d2b75c
6 changed files with 258 additions and 1 deletions

View File

@ -0,0 +1,17 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { scriptMonitorService } from '@/server/services/script-monitor.service'
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await context.params
return jsonSuccess(await scriptMonitorService.deleteAgent(id), 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ScriptMonitorOverview } from '@/lib/supply-chain-script-monitor'
import { ScriptMonitorView } from './ScriptMonitorView'
@ -132,4 +132,48 @@ describe('ScriptMonitorView', () => {
expect(screen.getByText(/不计入已接入机器/)).toBeInTheDocument()
expect(screen.getByText('共 0 台已接入')).toBeInTheDocument()
})
it('confirms the monitoring impact before deleting a machine', async () => {
const originalFetch = global.fetch
const emptyOverview: ScriptMonitorOverview = {
...overview,
agents: [],
scripts: [],
metrics: { agentTotal: 0, agentConnected: 0, agentOnline: 0, agentPending: 0, scriptTotal: 0, healthy: 0, running: 0, abnormal: 0, unknown: 0 },
}
global.fetch = jest.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
success: true,
data: { id: 'agent-1', name: '仓库服务器 01', deletedScripts: 1, deletedRuns: 2, deletedCommands: 0 },
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ success: true, data: emptyOverview }),
}) as typeof fetch
try {
render(<ScriptMonitorView initialOverview={overview} canEdit databaseReady />)
fireEvent.click(screen.getByRole('button', { name: '删除机器 仓库服务器 01' }))
expect(screen.getByRole('heading', { name: '删除“仓库服务器 01”' })).toBeInTheDocument()
expect(screen.getByText(/这台机器、1 个脚本/)).toBeInTheDocument()
expect(screen.getByText(/真实脚本和文件不会被删除/)).toBeInTheDocument()
expect(screen.getByText(/Agent 当前在线/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '确认删除' }))
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
'/api/supply-chain/script-monitor/agents/agent-1',
{ method: 'DELETE' },
))
await waitFor(() => expect(screen.getByText(/已删除“仓库服务器 01”及 1 个脚本/)).toBeInTheDocument())
} finally {
global.fetch = originalFetch
}
})
})

View File

@ -14,6 +14,7 @@ import {
Search,
Server,
SquareTerminal,
Trash2,
TriangleAlert,
Wifi,
WifiOff,
@ -175,6 +176,8 @@ export function ScriptMonitorView({
const [connectOpen, setConnectOpen] = useState(false)
const [confirmRun, setConfirmRun] = useState<ScriptMonitorScriptRow | null>(null)
const [runRequesting, setRunRequesting] = useState(false)
const [confirmDeleteAgent, setConfirmDeleteAgent] = useState<ScriptMonitorOverview['agents'][number] | null>(null)
const [agentDeleting, setAgentDeleting] = useState(false)
const refresh = useCallback(async (quiet = false) => {
if (!databaseReady) return
@ -274,6 +277,30 @@ export function ScriptMonitorView({
}
}
async function deleteAgent() {
if (!confirmDeleteAgent) return
const agent = confirmDeleteAgent
setAgentDeleting(true)
setError(null)
try {
const result = await readApi<{
name: string
deletedScripts: number
}>(await fetch(`/api/supply-chain/script-monitor/agents/${agent.id}`, {
method: 'DELETE',
}))
if (selectedScript?.agent.id === agent.id) setSelectedId(null)
if (agentFilter === agent.id) setAgentFilter('ALL')
setConfirmDeleteAgent(null)
setNotice(`已删除“${result.name}”及 ${result.deletedScripts} 个脚本的监控数据`)
await refresh(true)
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '机器删除失败,请重试')
} finally {
setAgentDeleting(false)
}
}
return (
<div className="monitor-page">
<header className="monitor-header">
@ -384,6 +411,17 @@ export function ScriptMonitorView({
<div className="monitor-agent-meta">
<span>{agent.scriptCount} 个脚本</span>
<span>{agent.lastSeenAt ? `最后心跳 ${formatDateTime(agent.lastSeenAt)}` : '凭据已生成,尚未连接'}</span>
{canEdit ? (
<button
type="button"
className="monitor-agent-delete"
aria-label={`删除机器 ${agent.name}`}
title="删除这台机器的监控数据"
onClick={() => setConfirmDeleteAgent(agent)}
>
<Trash2 size={13} aria-hidden="true" />删除
</button>
) : null}
</div>
</div>
</td>
@ -470,6 +508,14 @@ export function ScriptMonitorView({
onConfirm={requestManualRun}
/>
) : null}
{confirmDeleteAgent ? (
<ConfirmDeleteAgentDialog
agent={confirmDeleteAgent}
submitting={agentDeleting}
onCancel={() => setConfirmDeleteAgent(null)}
onConfirm={deleteAgent}
/>
) : null}
<style jsx>{`
.monitor-page { display:flex; min-height:0; flex:1; flex-direction:column; color:var(--sc-ink); background:var(--sc-bg); }
@ -525,6 +571,7 @@ export function ScriptMonitorView({
.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-delete { display:inline-flex; min-height:28px; flex:none; align-items:center; gap:5px; border:1px solid transparent; border-radius:7px; padding:0 8px; color:#8e4845; font-size:10px; font-weight:650; white-space:nowrap; }.monitor-agent-delete:hover { border-color:#e2c4c2; background:#fff7f6; color:#7f3735; }.monitor-agent-delete:focus-visible { outline:2px solid #9f6763; outline-offset:2px; }
.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; }
@ -656,6 +703,68 @@ function ConfirmRunDialog({
)
}
function ConfirmDeleteAgentDialog({
agent,
submitting,
onCancel,
onConfirm,
}: {
agent: ScriptMonitorOverview['agents'][number]
submitting: boolean
onCancel: () => void
onConfirm: () => void
}) {
return (
<div
className="monitor-delete-backdrop"
role="presentation"
onMouseDown={(event) => {
if (!submitting && event.currentTarget === event.target) onCancel()
}}
>
<section className="monitor-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-delete-agent-title">
<header>
<div>
<h2 id="confirm-delete-agent-title">删除“{agent.name}”</h2>
<p>这是不可撤销的监控数据清理。</p>
</div>
<button type="button" onClick={onCancel} disabled={submitting} aria-label="关闭"><X size={17} /></button>
</header>
<div className="monitor-delete-body">
<div className="monitor-delete-impact">
<strong>将从平台删除</strong>
<p>这台机器、{agent.scriptCount} 个脚本,以及关联的运行记录和手动执行记录。</p>
</div>
<p>目标电脑上的真实脚本和文件不会被删除。</p>
{agent.online ? (
<div className="monitor-delete-warning" role="alert">
<TriangleAlert size={16} aria-hidden="true" />
<span>Agent 当前在线。请先在目标机器停止 Agent,否则删除后它会因凭据失效持续上报失败。</span>
</div>
) : null}
<footer>
<button type="button" onClick={onCancel} disabled={submitting}>取消</button>
<button type="button" className="is-danger" onClick={onConfirm} disabled={submitting}>
{submitting ? <LoaderCircle size={14} className="animate-spin" /> : <Trash2 size={14} />}
{submitting ? '删除中' : '确认删除'}
</button>
</footer>
</div>
</section>
<style jsx>{`
.monitor-delete-backdrop { position:fixed; inset:0; z-index:90; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(28,32,34,.28); }
.monitor-delete-dialog { width:min(520px,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); } header button:disabled { opacity:.45; }
.monitor-delete-body { display:flex; flex-direction:column; gap:13px; padding:18px; }.monitor-delete-body > p { color:var(--sc-muted); font-size:11px; line-height:1.65; }
.monitor-delete-impact { border:1px solid var(--sc-border); border-radius:8px; padding:11px 12px; background:var(--sc-surface-muted); }.monitor-delete-impact strong { font-size:11px; }.monitor-delete-impact p { margin-top:5px; color:var(--sc-muted); font-size:11px; line-height:1.6; }
.monitor-delete-warning { display:flex; align-items:flex-start; gap:8px; border:1px solid #e2c4c2; border-radius:8px; padding:10px 11px; background:#fff7f6; color:#7f3735; font-size:11px; line-height:1.6; }.monitor-delete-warning svg { flex:none; margin-top:1px; }
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-danger { border-color:#8e4845; background:#8e4845; color:#fff; } footer button.is-danger:hover:not(:disabled) { background:#7f3735; } footer button:disabled { opacity:.55; }
@media (max-width:620px) { .monitor-delete-backdrop { align-items:flex-end; padding:0; }.monitor-delete-dialog { border-radius:14px 14px 0 0; } }
`}</style>
</div>
)
}
function ConnectAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [name, setName] = useState('')
const [networkMode, setNetworkMode] = useState<ScriptMonitorNetworkMode>('AUTO')

View File

@ -119,6 +119,13 @@ export interface ScriptMonitorRepository {
jobType: ScriptMonitorJobType
}
} | null>
deleteAgent(id: string): Promise<{
id: string
name: string
scriptCount: number
runCount: number
commandCount: number
} | null>
listOverview(): Promise<ScriptMonitorAgentOverviewRecord[]>
listRuns(scriptId: string, take: number): Promise<Array<{
id: string
@ -453,6 +460,35 @@ export class PrismaScriptMonitorRepository implements ScriptMonitorRepository {
})
}
deleteAgent(id: string) {
return prisma.$transaction(async (tx) => {
const agent = await tx.scriptMonitorAgent.findUnique({
where: { id },
select: {
id: true,
name: true,
_count: {
select: {
scripts: true,
runs: true,
commands: true,
},
},
},
})
if (!agent) return null
const deleted = await tx.scriptMonitorAgent.deleteMany({ where: { id } })
if (deleted.count !== 1) return null
return {
id: agent.id,
name: agent.name,
scriptCount: agent._count.scripts,
runCount: agent._count.runs,
commandCount: agent._count.commands,
}
})
}
listOverview() {
return prisma.scriptMonitorAgent.findMany({
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],

View File

@ -15,6 +15,7 @@ function repositoryMock(): jest.Mocked<ScriptMonitorRepository> {
findScriptForManualRun: jest.fn(),
createRunCommand: jest.fn(),
claimPendingCommand: jest.fn(),
deleteAgent: jest.fn(),
listOverview: jest.fn(),
listRuns: jest.fn(),
}
@ -160,4 +161,33 @@ describe('ScriptMonitorService', () => {
requestedById: 'user-1',
}))
})
it('deletes one machine and returns the cascaded monitoring record counts', async () => {
const repository = repositoryMock()
repository.deleteAgent.mockResolvedValue({
id: 'agent-1',
name: '仓库服务器 01',
scriptCount: 3,
runCount: 12,
commandCount: 2,
})
const service = new ScriptMonitorService(repository)
await expect(service.deleteAgent(' agent-1 ')).resolves.toEqual({
id: 'agent-1',
name: '仓库服务器 01',
deletedScripts: 3,
deletedRuns: 12,
deletedCommands: 2,
})
expect(repository.deleteAgent).toHaveBeenCalledWith('agent-1')
})
it('reports a clear error when deleting a missing machine', async () => {
const repository = repositoryMock()
repository.deleteAgent.mockResolvedValue(null)
const service = new ScriptMonitorService(repository)
await expect(service.deleteAgent('missing-agent')).rejects.toThrow('脚本监控机器不存在或已删除')
})
})

View File

@ -198,6 +198,27 @@ export class ScriptMonitorService {
}
}
async deleteAgent(id: string) {
const agentId = id.trim()
if (!agentId) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少脚本监控机器 ID')
}
if (agentId.length > 80) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '脚本监控机器 ID 不正确')
}
const deleted = await this.repository.deleteAgent(agentId)
if (!deleted) {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '脚本监控机器不存在或已删除')
}
return {
id: deleted.id,
name: deleted.name,
deletedScripts: deleted.scriptCount,
deletedRuns: deleted.runCount,
deletedCommands: deleted.commandCount,
}
}
async getOverview(): Promise<ScriptMonitorOverview> {
const records = await this.repository.listOverview()
const now = Date.now()