feat: #22 容器操作管理(重启/停止/查看日志)

- container-ops.ts: 通过 SSH 执行 Docker 命令
- /api/services/[id]/ops: 容器操作 API
- services/page.tsx: 操作按钮 + 日志查看
This commit is contained in:
aiyimickey 2026-07-02 09:21:48 +08:00
parent 2a14d3f4a2
commit 51cfcab11e
3 changed files with 222 additions and 11 deletions

View File

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

View File

@ -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">&times;</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>
) )
} }

80
src/lib/container-ops.ts Normal file
View File

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