From 51cfcab11ef7ca8024572f89e36a04db4e0742e6 Mon Sep 17 00:00:00 2001 From: aiyimickey <39365912+aiyimickey@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:21:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20#22=20=E5=AE=B9=E5=99=A8=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E7=AE=A1=E7=90=86=EF=BC=88=E9=87=8D=E5=90=AF/?= =?UTF-8?q?=E5=81=9C=E6=AD=A2/=E6=9F=A5=E7=9C=8B=E6=97=A5=E5=BF=97?= =?UTF-8?q?=EF=BC=89=20-=20container-ops.ts:=20=E9=80=9A=E8=BF=87=20SSH=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=20Docker=20=E5=91=BD=E4=BB=A4=20-=20/api/ser?= =?UTF-8?q?vices/[id]/ops:=20=E5=AE=B9=E5=99=A8=E6=93=8D=E4=BD=9C=20API=20?= =?UTF-8?q?-=20services/page.tsx:=20=E6=93=8D=E4=BD=9C=E6=8C=89=E9=92=AE?= =?UTF-8?q?=20+=20=E6=97=A5=E5=BF=97=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/services/[id]/ops/route.ts | 61 +++++++++++++++++ src/app/services/page.tsx | 92 +++++++++++++++++++++++--- src/lib/container-ops.ts | 80 ++++++++++++++++++++++ 3 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 src/app/api/services/[id]/ops/route.ts create mode 100644 src/lib/container-ops.ts diff --git a/src/app/api/services/[id]/ops/route.ts b/src/app/api/services/[id]/ops/route.ts new file mode 100644 index 0000000..f31b866 --- /dev/null +++ b/src/app/api/services/[id]/ops/route.ts @@ -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) => 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) +} diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 0727fff..ae1340b 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -1,30 +1,70 @@ 'use client' -// src/app/services/page.tsx — 服务管理页面 +// src/app/services/page.tsx — 服务管理页面(含容器操作) 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() { const [services, setServices] = useState([]) const [loading, setLoading] = useState(true) + const [opLoading, setOpLoading] = useState(null) + const [logs, setLogs] = useState<{ serviceId: number; output: string } | null>(null) + const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null) useEffect(() => { fetchServices() }, []) async function fetchServices() { const res = await fetch('/api/services') - const data = await res.json() - setServices(data) + setServices(await res.json()) 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
加载中...
return (
+ {toast && ( +
+ {toast.msg} +
+ )} +

服务管理

-
@@ -38,7 +78,7 @@ export default function ServicesPage() { 告警级别 检查方式 状态 - 操作 + 容器操作 @@ -58,10 +98,25 @@ export default function ServicesPage() { {s.current_status === 'normal' ? '正常' : s.current_status === 'abnormal' ? '异常' : '未知'} - - - - + + {hasDocker(s) ? ( +
+ + + +
+ ) : ( + + )} ))} @@ -69,6 +124,21 @@ export default function ServicesPage() { {services.length === 0 &&

暂无服务,请添加被监控服务

}
+ + {/* 日志查看 Dialog */} + {logs && ( +
setLogs(null)}> +
e.stopPropagation()}> +
+

容器日志 — {services.find(s => s.id === logs.serviceId)?.name}

+ +
+
+              {logs.output}
+            
+
+
+ )} ) } diff --git a/src/lib/container-ops.ts b/src/lib/container-ops.ts new file mode 100644 index 0000000..51e6b4c --- /dev/null +++ b/src/lib/container-ops.ts @@ -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 { + 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 + } +}