feat: #22 容器操作管理(重启/停止/查看日志)
- container-ops.ts: 通过 SSH 执行 Docker 命令 - /api/services/[id]/ops: 容器操作 API - services/page.tsx: 操作按钮 + 日志查看
This commit is contained in:
parent
2a14d3f4a2
commit
51cfcab11e
|
|
@ -0,0 +1,61 @@
|
||||||
|
// POST /api/services/[id]/ops — 容器操作(重启/停止/查看日志)
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
|
import { authConfig } from '@/lib/auth-config'
|
||||||
|
import { dbQuery } from '@/lib/db'
|
||||||
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { containerOp, type ContainerAction } from '@/lib/container-ops'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const token = request.cookies.get('tlyq_session')?.value
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||||
|
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'services:manage')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json() as { action: ContainerAction; containerName?: string; lines?: number }
|
||||||
|
const { action, containerName, lines } = body
|
||||||
|
|
||||||
|
if (!action || !['restart', 'stop', 'start', 'logs'].includes(action)) {
|
||||||
|
return NextResponse.json({ error: '无效操作' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取服务信息,确定容器名
|
||||||
|
let targetContainer = containerName
|
||||||
|
if (!targetContainer) {
|
||||||
|
const services = dbQuery<{ name: string; checks: string }>(`SELECT name, checks FROM services WHERE id = ${Number(id)}`)
|
||||||
|
if (services.length === 0) {
|
||||||
|
return NextResponse.json({ error: '服务不存在' }, { status: 404 })
|
||||||
|
}
|
||||||
|
// 从 checks JSON 中提取 Docker 容器名
|
||||||
|
try {
|
||||||
|
const checks = JSON.parse(services[0].checks || '[]')
|
||||||
|
const dockerCheck = checks.find((c: Record<string, unknown>) => c.type === 'docker')
|
||||||
|
if (dockerCheck?.containerName) {
|
||||||
|
targetContainer = String(dockerCheck.containerName)
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: '该服务未配置 Docker 检查,无法操作' }, { status: 400 })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: '服务配置解析失败' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await containerOp(targetContainer, action, lines || 50)
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
writeAuditLog({
|
||||||
|
userId: Number(payload.sub) || null,
|
||||||
|
username: String(payload.username || ''),
|
||||||
|
action: `container_${action}`,
|
||||||
|
entityType: 'service',
|
||||||
|
entityId: Number(id),
|
||||||
|
details: { containerName: targetContainer, action, success: result.success, output: result.output?.slice(0, 200) },
|
||||||
|
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
}
|
||||||
|
|
@ -1,30 +1,70 @@
|
||||||
'use client'
|
'use client'
|
||||||
// src/app/services/page.tsx — 服务管理页面
|
// src/app/services/page.tsx — 服务管理页面(含容器操作)
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Server, Plus, Edit, Trash2, Eye } from 'lucide-react'
|
import { Server, Plus, RotateCw, Square, ScrollText } from 'lucide-react'
|
||||||
|
|
||||||
interface Service { id: number; name: string; category: string; alert_level: string; checks: Array<{type: string}>; check_interval: number; enabled: number; current_status: string }
|
interface Service { id: number; name: string; category: string; alert_level: string; checks: Array<{type: string; containerName?: string}>; check_interval: number; enabled: number; current_status: string }
|
||||||
|
|
||||||
export default function ServicesPage() {
|
export default function ServicesPage() {
|
||||||
const [services, setServices] = useState<Service[]>([])
|
const [services, setServices] = useState<Service[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [opLoading, setOpLoading] = useState<number | null>(null)
|
||||||
|
const [logs, setLogs] = useState<{ serviceId: number; output: string } | null>(null)
|
||||||
|
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||||
|
|
||||||
useEffect(() => { fetchServices() }, [])
|
useEffect(() => { fetchServices() }, [])
|
||||||
|
|
||||||
async function fetchServices() {
|
async function fetchServices() {
|
||||||
const res = await fetch('/api/services')
|
const res = await fetch('/api/services')
|
||||||
const data = await res.json()
|
setServices(await res.json())
|
||||||
setServices(data)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showToast = (type: 'ok' | 'err', msg: string) => {
|
||||||
|
setToast({ type, msg }); setTimeout(() => setToast(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDocker = (s: Service) => s.checks?.some(c => c.type === 'docker' && c.containerName)
|
||||||
|
|
||||||
|
async function containerAction(s: Service, action: 'restart' | 'stop' | 'start' | 'logs') {
|
||||||
|
const confirmMsg = { restart: `确定重启 ${s.name}?`, stop: `确定停止 ${s.name}?`, start: `确定启动 ${s.name}?`, logs: '' }
|
||||||
|
if (action !== 'logs' && !confirm(confirmMsg[action])) return
|
||||||
|
|
||||||
|
setOpLoading(s.id)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/services/${s.id}/ops`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action, lines: 100 }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
if (action === 'logs') {
|
||||||
|
setLogs({ serviceId: s.id, output: data.output || '(空)' })
|
||||||
|
} else {
|
||||||
|
showToast('ok', `${s.name} ${action === 'restart' ? '重启' : action === 'stop' ? '停止' : '启动'}成功`)
|
||||||
|
fetchServices()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('err', data.error || '操作失败')
|
||||||
|
}
|
||||||
|
} catch { showToast('err', '网络错误') }
|
||||||
|
setOpLoading(null)
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div className="p-12 text-center text-slate-400">加载中...</div>
|
if (loading) return <div className="p-12 text-center text-slate-400">加载中...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{toast && (
|
||||||
|
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg text-sm font-medium shadow-lg ${toast.type === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}>
|
||||||
|
{toast.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">服务管理</h1>
|
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">服务管理</h1>
|
||||||
<button className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-4 py-2 text-sm font-medium" onClick={() => alert('添加服务(完整 UI 待 Phase 4 后续实现)')}>
|
<button className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-4 py-2 text-sm font-medium"
|
||||||
|
onClick={() => showToast('err', '添加服务功能即将上线')}>
|
||||||
<Plus size={16} /> 添加服务
|
<Plus size={16} /> 添加服务
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -38,7 +78,7 @@ export default function ServicesPage() {
|
||||||
<th className="text-left px-4 py-3 font-medium text-slate-500">告警级别</th>
|
<th className="text-left px-4 py-3 font-medium text-slate-500">告警级别</th>
|
||||||
<th className="text-left px-4 py-3 font-medium text-slate-500">检查方式</th>
|
<th className="text-left px-4 py-3 font-medium text-slate-500">检查方式</th>
|
||||||
<th className="text-left px-4 py-3 font-medium text-slate-500">状态</th>
|
<th className="text-left px-4 py-3 font-medium text-slate-500">状态</th>
|
||||||
<th className="text-right px-4 py-3 font-medium text-slate-500">操作</th>
|
<th className="text-center px-4 py-3 font-medium text-slate-500">容器操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -58,10 +98,25 @@ export default function ServicesPage() {
|
||||||
{s.current_status === 'normal' ? '正常' : s.current_status === 'abnormal' ? '异常' : '未知'}
|
{s.current_status === 'normal' ? '正常' : s.current_status === 'abnormal' ? '异常' : '未知'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-center">
|
||||||
<button className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-400" title="查看"><Eye size={16} /></button>
|
{hasDocker(s) ? (
|
||||||
<button className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-400" title="编辑"><Edit size={16} /></button>
|
<div className="flex items-center justify-center gap-1">
|
||||||
<button className="p-1.5 hover:bg-red-50 dark:hover:bg-red-500/10 rounded text-red-400" title="删除"><Trash2 size={16} /></button>
|
<button onClick={() => containerAction(s, 'restart')} disabled={opLoading === s.id}
|
||||||
|
className="p-1.5 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 rounded text-indigo-500 disabled:opacity-40" title="重启">
|
||||||
|
<RotateCw size={15} className={opLoading === s.id ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => containerAction(s, 'stop')} disabled={opLoading === s.id}
|
||||||
|
className="p-1.5 hover:bg-red-50 dark:hover:bg-red-500/10 rounded text-red-500 disabled:opacity-40" title="停止">
|
||||||
|
<Square size={15} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => containerAction(s, 'logs')} disabled={opLoading === s.id}
|
||||||
|
className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-500 disabled:opacity-40" title="查看日志">
|
||||||
|
<ScrollText size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-400">—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -69,6 +124,21 @@ export default function ServicesPage() {
|
||||||
</table>
|
</table>
|
||||||
{services.length === 0 && <p className="p-12 text-center text-slate-400">暂无服务,请添加被监控服务</p>}
|
{services.length === 0 && <p className="p-12 text-center text-slate-400">暂无服务,请添加被监控服务</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 日志查看 Dialog */}
|
||||||
|
{logs && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setLogs(null)}>
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 w-full max-w-3xl shadow-xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-semibold">容器日志 — {services.find(s => s.id === logs.serviceId)?.name}</h2>
|
||||||
|
<button onClick={() => setLogs(null)} className="text-slate-400 hover:text-slate-600 text-lg">×</button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs font-mono text-slate-300 bg-slate-950 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap max-h-96">
|
||||||
|
{logs.output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
// src/lib/container-ops.ts — 容器操作(通过 SSH 执行 Docker 命令)
|
||||||
|
import { execFileSync } from 'child_process'
|
||||||
|
|
||||||
|
export type ContainerAction = 'restart' | 'stop' | 'start' | 'logs'
|
||||||
|
|
||||||
|
export interface ContainerOpResult {
|
||||||
|
success: boolean
|
||||||
|
action: ContainerAction
|
||||||
|
containerName: string
|
||||||
|
output?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 SSH 在 txjp 服务器上执行 Docker 命令
|
||||||
|
function sshExec(cmd: string, timeoutMs = 15000): { stdout: string; stderr: string } {
|
||||||
|
try {
|
||||||
|
const result = execFileSync('ssh', ['txjp', cmd], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: timeoutMs,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
})
|
||||||
|
return { stdout: result.trim(), stderr: '' }
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { stderr?: Buffer; stdout?: Buffer; status?: number }
|
||||||
|
return {
|
||||||
|
stdout: e.stdout?.toString()?.trim() || '',
|
||||||
|
stderr: e.stderr?.toString()?.trim() || String(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行容器操作
|
||||||
|
export async function containerOp(
|
||||||
|
containerName: string,
|
||||||
|
action: ContainerAction,
|
||||||
|
lines?: number,
|
||||||
|
): Promise<ContainerOpResult> {
|
||||||
|
if (!containerName) {
|
||||||
|
return { success: false, action, containerName: '', error: '容器名不能为空' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let cmd: string
|
||||||
|
switch (action) {
|
||||||
|
case 'restart':
|
||||||
|
cmd = `docker restart ${containerName}`
|
||||||
|
break
|
||||||
|
case 'stop':
|
||||||
|
cmd = `docker stop ${containerName}`
|
||||||
|
break
|
||||||
|
case 'start':
|
||||||
|
cmd = `docker start ${containerName}`
|
||||||
|
break
|
||||||
|
case 'logs':
|
||||||
|
const n = Math.min(lines || 50, 200)
|
||||||
|
cmd = `docker logs --tail ${n} ${containerName}`
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return { success: false, action, containerName, error: '未知操作' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = sshExec(cmd)
|
||||||
|
if (stderr && !stdout) {
|
||||||
|
return { success: false, action, containerName, error: stderr }
|
||||||
|
}
|
||||||
|
return { success: true, action, containerName, output: stdout || 'OK' }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, action, containerName, error: String(err) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取容器状态
|
||||||
|
export function getContainerStatus(containerName: string): string | null {
|
||||||
|
try {
|
||||||
|
const { stdout } = sshExec(`docker ps --format '{{.Status}}' -f name=^${containerName}$`)
|
||||||
|
return stdout || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue