merge: release script monitor machine deletion
This commit is contained in:
commit
dc16d2b75c
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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' }],
|
||||
|
||||
@ -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('脚本监控机器不存在或已删除')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user