修复权限管理,角色认证,更新工单认领系统,增加表格导出支持区间选择,增加docker管理(开发中
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
session_start();
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header("Content-Type:application/json;charset=utf-8");
|
||||
|
||||
$SECRET_RAW = "DockerAdminPlatform2026SecretKey123456789";
|
||||
|
||||
$uidGet = $_GET['uid'] ?? '';
|
||||
if (!empty($uidGet) && empty($_SESSION['user_id'])) {
|
||||
$_SESSION['user_id'] = $uidGet;
|
||||
$_SESSION['login_time'] = time();
|
||||
}
|
||||
|
||||
$debug = [
|
||||
"sid" => session_id(),
|
||||
"session_data" => $_SESSION
|
||||
];
|
||||
|
||||
$expire = 1800;
|
||||
if (empty($_SESSION['user_id']) || (time() - $_SESSION['login_time'] > $expire)) {
|
||||
session_destroy();
|
||||
echo json_encode([
|
||||
"code"=>401,
|
||||
"msg"=>"未登录",
|
||||
"debug"=>$debug
|
||||
],JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = $_SESSION['user_id'];
|
||||
$role = $_SESSION['role'];
|
||||
$ts = time();
|
||||
$rawSignStr = $user . "|" . $role . "|" . $ts;
|
||||
$sign = hash_hmac("sha256", $rawSignStr, $SECRET_RAW);
|
||||
$clientIp = $_SERVER['REMOTE_ADDR'];
|
||||
|
||||
$act = $_POST['act'] ?? $_GET['act'] ?? 'list';
|
||||
$cid = $_POST['container_id'] ?? $_GET['container_id'] ?? '';
|
||||
$imageId = $_POST['image_id'] ?? $_GET['image_id'] ?? '';
|
||||
$lines = $_POST['lines'] ?? $_GET['lines'] ?? 200;
|
||||
$postRaw = file_get_contents("php://input");
|
||||
$apiBase = "http://10.150.117.190:8090";
|
||||
|
||||
$authHeaders = [
|
||||
"X-User: {$user}",
|
||||
"X-Role: {$role}",
|
||||
"X-Time: {$ts}",
|
||||
"X-Sign: {$sign}",
|
||||
"X-Client-Ip: {$clientIp}",
|
||||
"Content-Type: application/json; charset=utf-8"
|
||||
];
|
||||
$headerStr = implode("\r\n", $authHeaders);
|
||||
|
||||
$getMap = [
|
||||
'list' => '/list',
|
||||
'image_list' => '/image_list',
|
||||
'restart' => '/restart/%s',
|
||||
'stop' => '/stop/%s',
|
||||
'rm' => '/rm/%s',
|
||||
'remove_image' => '/remove_image/%s',
|
||||
'logs' => '/logs/%s/%d',
|
||||
'network' => '/network/%s',
|
||||
'stats' => '/stats/%s',
|
||||
'detail' => '/detail/%s'
|
||||
];
|
||||
|
||||
$postMap = [
|
||||
'exec' => '/exec/%s',
|
||||
'create' => '/create',
|
||||
'bind_repo' => '/bind_repo',
|
||||
];
|
||||
|
||||
if (isset($getMap[$act])) {
|
||||
$path = $getMap[$act];
|
||||
$url = '';
|
||||
switch ($act) {
|
||||
case 'restart':
|
||||
case 'stop':
|
||||
case 'rm':
|
||||
case 'network':
|
||||
case 'stats':
|
||||
case 'detail':
|
||||
if (empty($cid)) {
|
||||
echo json_encode(["code"=>400,"msg"=>"缺少容器ID","debug"=>$debug],JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$url = $apiBase . sprintf($path, urlencode($cid));
|
||||
break;
|
||||
case 'remove_image':
|
||||
if (empty($imageId)) {
|
||||
echo json_encode(["code"=>400,"msg"=>"缺少镜像ID","debug"=>$debug],JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$url = $apiBase . sprintf($path, urlencode($imageId));
|
||||
break;
|
||||
case 'logs':
|
||||
if (empty($cid)) {
|
||||
echo json_encode(["code"=>400,"msg"=>"缺少容器ID","debug"=>$debug],JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$url = $apiBase . sprintf($path, urlencode($cid), (int)$lines);
|
||||
break;
|
||||
default:
|
||||
$url = $apiBase . $path;
|
||||
break;
|
||||
}
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => $headerStr,
|
||||
'timeout' => 30
|
||||
]
|
||||
]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
if ($resp === false) {
|
||||
echo json_encode([
|
||||
"code" => 503,
|
||||
"msg" => "后端Docker接口连接失败,请检查服务是否启动",
|
||||
"debug" => $debug
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
echo $resp;
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($postMap[$act])) {
|
||||
$path = $postMap[$act];
|
||||
$url = '';
|
||||
if ($act === 'exec') {
|
||||
if (empty($cid)) {
|
||||
echo json_encode(["code"=>400,"msg"=>"缺少容器ID","debug"=>$debug],JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$url = $apiBase . sprintf($path, urlencode($cid));
|
||||
} else {
|
||||
$url = $apiBase . $path;
|
||||
}
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => $headerStr,
|
||||
'content' => $postRaw,
|
||||
'timeout' => 30
|
||||
]
|
||||
]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
if ($resp === false) {
|
||||
echo json_encode([
|
||||
"code" => 503,
|
||||
"msg" => "后端Docker接口连接失败,请检查服务是否启动",
|
||||
"debug" => $debug
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
echo $resp;
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"code"=>400,
|
||||
"msg"=>"无效操作act参数",
|
||||
"debug"=>$debug
|
||||
],JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
@@ -0,0 +1,752 @@
|
||||
import json
|
||||
import shlex
|
||||
import time
|
||||
import hashlib
|
||||
import hmac
|
||||
import docker
|
||||
from flask import Flask, jsonify, request
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
# 全局签名密钥,前后端统一,生产环境使用环境变量注入
|
||||
SIGN_SECRET = b"DockerAdminPlatform2026SecretKey123456789"
|
||||
# 签名有效期 300秒(5分钟)
|
||||
SIGN_EXPIRE = 300
|
||||
|
||||
# 兼容新旧docker-py客户端
|
||||
try:
|
||||
cli = docker.DockerClient(base_url='unix:///var/run/docker.sock')
|
||||
api_cli = docker.APIClient(base_url='unix:///var/run/docker.sock')
|
||||
except AttributeError:
|
||||
cli = docker.Client(base_url='unix:///var/run/docker.sock')
|
||||
api_cli = docker.APIClient(base_url='unix:///var/run/docker.sock')
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'docker-terminal-secret-2026'
|
||||
|
||||
# WebSocket心跳保活,防止Nginx/反向代理断连
|
||||
socketio = SocketIO(
|
||||
app,
|
||||
cors_allowed_origins="*",
|
||||
async_mode="threading",
|
||||
ping_timeout=60,
|
||||
ping_interval=25
|
||||
)
|
||||
|
||||
# 全局会话存储
|
||||
session_exec_map = {} # sid => TTY终端流信息
|
||||
log_task_map = {} # sid => {"cid": "", "running": True}
|
||||
ws_user_map = {} # 新增:sid 映射 WebSocket鉴权用户信息
|
||||
registry_config = {
|
||||
"repo_name": "",
|
||||
"repo_addr": "",
|
||||
"repo_user": "",
|
||||
"repo_pwd": ""
|
||||
}
|
||||
# 多系统shell兼容列表(Ubuntu/Alpine/标准Linux)
|
||||
SHELL_CANDIDATES = [
|
||||
"/bin/bash",
|
||||
"/usr/bin/bash",
|
||||
"/bin/sh",
|
||||
"/bin/ash"
|
||||
]
|
||||
|
||||
# ===================== 工具函数1:docker socket兼容recv =====================
|
||||
def docker_recv(sock, size=1024):
|
||||
try:
|
||||
return sock.recv(size)
|
||||
except Exception:
|
||||
try:
|
||||
return sock._sock.recv(size)
|
||||
except Exception:
|
||||
return b""
|
||||
|
||||
# ===================== 工具函数2:操作审计日志(终端输入脱敏,不存明文密码) =====================
|
||||
def write_audit_log(user, role, action, target_name, target_id, result, client_ip):
|
||||
"""企业审计记录,可对接数据库docker_audit_log表"""
|
||||
# 终端输入仅标记事件,不记录明文命令,规避密码泄露风险
|
||||
if action == "terminal_input":
|
||||
result = "command_entered"
|
||||
log_item = {
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"user": user,
|
||||
"role": role,
|
||||
"action": action,
|
||||
"container_name": target_name,
|
||||
"container_id": target_id,
|
||||
"result": result,
|
||||
"client_ip": client_ip
|
||||
}
|
||||
# 生产环境替换为mysql insert语句
|
||||
print(json.dumps(log_item, ensure_ascii=False))
|
||||
|
||||
# ===================== 工具函数3:HTTP Header HMAC签名鉴权(原有,HTTP接口专用) =====================
|
||||
def verify_auth_sign():
|
||||
"""
|
||||
校验前端PHP生成的HMAC签名,防止伪造X-Role:admin
|
||||
请求Header要求:
|
||||
X-User: 用户名
|
||||
X-Role: admin/user/guest
|
||||
X-Time: 当前时间戳(秒)
|
||||
X-Sign: hmac-sha256签名
|
||||
返回 (user, role, client_ip, True/False)
|
||||
"""
|
||||
user = request.headers.get("X-User", "anonymous")
|
||||
role = request.headers.get("X-Role", "guest")
|
||||
ts_str = request.headers.get("X-Time", "0")
|
||||
sign = request.headers.get("X-Sign", "")
|
||||
client_ip = request.headers.get("X-Client-Ip", request.remote_addr)
|
||||
try:
|
||||
ts = int(ts_str)
|
||||
except ValueError:
|
||||
return user, role, client_ip, False
|
||||
# 校验签名有效期
|
||||
now = int(time.time())
|
||||
if abs(now - ts) > SIGN_EXPIRE:
|
||||
return user, role, client_ip, False
|
||||
# 校验HMAC签名
|
||||
raw_data = f"{user}|{role}|{ts}".encode("utf-8")
|
||||
calc_sign = hmac.new(SIGN_SECRET, raw_data, hashlib.sha256).hexdigest()
|
||||
if calc_sign != sign:
|
||||
return user, role, client_ip, False
|
||||
return user, role, client_ip, True
|
||||
|
||||
# ===================== 新增工具函数4:WebSocket连接专用鉴权函数 =====================
|
||||
def verify_socket_auth(auth):
|
||||
user = auth.get("user", "")
|
||||
role = auth.get("role", "")
|
||||
ts_str = auth.get("ts", "0")
|
||||
sign = auth.get("sign", "")
|
||||
try:
|
||||
ts = int(ts_str)
|
||||
except:
|
||||
return user, role, "", False
|
||||
if abs(int(time.time()) - ts) > SIGN_EXPIRE:
|
||||
return user, role, "", False
|
||||
raw_data = f"{user}|{role}|{ts}".encode("utf-8")
|
||||
calc_sign = hmac.new(
|
||||
SIGN_SECRET,
|
||||
raw_data,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
if calc_sign != sign:
|
||||
return user, role, "", False
|
||||
return user, role, "", True
|
||||
|
||||
# ===================== 工具函数5:环境变量脱敏,屏蔽密码/密钥 =====================
|
||||
def safe_env_filter(env_list):
|
||||
safe_env = []
|
||||
sensitive_keywords = ["PASS", "PASSWORD", "TOKEN", "SECRET", "KEY", "CREDENTIAL"]
|
||||
for item in env_list:
|
||||
equal_pos = item.find("=")
|
||||
if equal_pos == -1:
|
||||
safe_env.append(item)
|
||||
continue
|
||||
key = item[:equal_pos]
|
||||
val = item[equal_pos+1:]
|
||||
if any(word in key.upper() for word in sensitive_keywords):
|
||||
safe_env.append(f"{key}=******")
|
||||
else:
|
||||
safe_env.append(item)
|
||||
return safe_env
|
||||
|
||||
# ===================== 全部HTTP业务接口 =====================
|
||||
# 1. 容器列表(端口预拼接字符串,前端直接渲染,无需formatPorts)
|
||||
@app.route("/list")
|
||||
def api_list():
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "list_container", "", "", "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
arr = []
|
||||
for c in cli.containers.list(all=True):
|
||||
img_name = c.image.tags[0] if c.image.tags else ""
|
||||
port_str_arr = []
|
||||
ports_raw = c.ports
|
||||
for c_port, host_list in ports_raw.items():
|
||||
if not host_list:
|
||||
continue
|
||||
for host_item in host_list:
|
||||
host_port = host_item.get("HostPort", "")
|
||||
port_str_arr.append(f"{host_port}:{c_port.split('/')[0]}")
|
||||
port_display = ",".join(port_str_arr) if port_str_arr else "-"
|
||||
arr.append({
|
||||
"id": c.short_id,
|
||||
"full_id": c.id,
|
||||
"name": c.name,
|
||||
"image": img_name,
|
||||
"status": c.status,
|
||||
"ports": port_display
|
||||
})
|
||||
write_audit_log(user, role, "list_container", "", "", "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","list":arr})
|
||||
|
||||
# 2. 镜像列表
|
||||
@app.route("/image_list")
|
||||
def api_image_list():
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "list_image", "", "", "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
arr = []
|
||||
for img in cli.images.list():
|
||||
if not img.tags:
|
||||
continue
|
||||
full_tag = img.tags[0]
|
||||
repo, tag = full_tag.split(":", 1)
|
||||
size_mb = round(img.attrs["Size"] / 1024 / 1024, 1)
|
||||
arr.append({
|
||||
"repo": repo,
|
||||
"tag": tag,
|
||||
"id": img.short_id,
|
||||
"full_id": img.id,
|
||||
"size": f"{size_mb}MB",
|
||||
"created": img.attrs["Created"]
|
||||
})
|
||||
write_audit_log(user, role, "list_image", "", "", "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","list":arr})
|
||||
|
||||
# 3. 删除镜像
|
||||
@app.route("/remove_image/<image_id>")
|
||||
def api_remove_image(image_id):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "remove_image", "", image_id, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "remove_image", "", image_id, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可删除镜像"})
|
||||
try:
|
||||
img = cli.images.get(image_id)
|
||||
cli.images.remove(image_id, force=True)
|
||||
write_audit_log(user, role, "remove_image", img.tags[0] if img.tags else "", image_id, "success", ip)
|
||||
return jsonify({"code":0,"msg":"镜像已强制删除"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "remove_image", "", image_id, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 4. 重启容器
|
||||
@app.route("/restart/<cid>")
|
||||
def api_restart(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "restart", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "restart", "", cid, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可操作容器"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
container.restart()
|
||||
write_audit_log(user, role, "restart", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"容器已重启"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "restart", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 5. 停止容器
|
||||
@app.route("/stop/<cid>")
|
||||
def api_stop(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "stop", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "stop", "", cid, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可操作容器"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
container.stop()
|
||||
write_audit_log(user, role, "stop", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"容器已停止"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "stop", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 6. 删除容器
|
||||
@app.route("/rm/<cid>")
|
||||
def api_rm(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "rm", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "rm", "", cid, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可删除容器"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
container.remove(force=True)
|
||||
write_audit_log(user, role, "rm", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"容器已强制删除"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "rm", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 7. 一次性拉取日志(兼容旧页面单次查看)
|
||||
@app.route("/logs/<cid>/<int:tail_lines>")
|
||||
def api_logs(cid, tail_lines):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "read_log", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
log_bytes = container.logs(tail=tail_lines)
|
||||
log_str = log_bytes.decode("utf-8", errors="replace")
|
||||
write_audit_log(user, role, "read_log", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":log_str})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "read_log", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":""})
|
||||
|
||||
# 8. 容器网络详情
|
||||
@app.route("/network/<cid>")
|
||||
def api_network(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "read_network", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
info = container.attrs["NetworkSettings"]["Networks"]
|
||||
write_audit_log(user, role, "read_network", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":json.dumps(info,indent=2)})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "read_network", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":""})
|
||||
|
||||
# 9. 短连接单次执行命令(旧弹窗兼容,非实时终端)
|
||||
@app.route("/exec/<cid>", methods=["POST"])
|
||||
def api_exec(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "single_exec", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "single_exec", "", cid, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可执行命令"})
|
||||
try:
|
||||
data = request.get_json()
|
||||
cmd = data.get("cmd", "")
|
||||
if not cmd:
|
||||
return jsonify({"code":400,"msg":"命令不能为空","data":""})
|
||||
cmd_list = shlex.split(cmd)
|
||||
container = cli.containers.get(cid)
|
||||
exec_obj = container.exec_run(cmd_list, stdout=True, stderr=True)
|
||||
output = exec_obj.output.decode("utf-8", errors="replace")
|
||||
write_audit_log(user, role, "single_exec", container.name, cid, "command_executed", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":output})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "single_exec", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":""})
|
||||
|
||||
# 10. 创建容器(端口标准格式 + 前端可配置CPU/内存)
|
||||
@app.route("/create", methods=["POST"])
|
||||
def api_create():
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "create_container", "", "", "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "create_container", "", "", "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可创建容器"})
|
||||
try:
|
||||
data = request.get_json()
|
||||
name = data.get("name", "")
|
||||
image = data.get("image")
|
||||
cmd = data.get("cmd")
|
||||
ports_input = data.get("ports", {})
|
||||
# 修复:docker标准端口字典格式 {内部端口:宿主机端口}
|
||||
port_bind = {}
|
||||
for inner_port, host_port in ports_input.items():
|
||||
port_bind[f"{inner_port}/tcp"] = int(host_port)
|
||||
# 前端可配置资源限制,默认1核512M
|
||||
cpu_core = int(data.get("cpu", 1))
|
||||
memory_limit = data.get("memory", "512m")
|
||||
nano_cpus = cpu_core * 1000000000
|
||||
container = cli.containers.run(
|
||||
image=image,
|
||||
name=name if name else None,
|
||||
command=cmd if cmd else None,
|
||||
ports=port_bind,
|
||||
detach=True,
|
||||
restart_policy={"Name": "always"},
|
||||
mem_limit=memory_limit,
|
||||
nano_cpus=nano_cpus
|
||||
)
|
||||
write_audit_log(user, role, "create_container", container.name, container.id, "success", ip)
|
||||
return jsonify({"code":0,"msg":f"容器创建成功,ID:{container.short_id}"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "create_container", "", "", f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 11. 登录私有镜像仓库
|
||||
@app.route("/bind_repo", methods=["POST"])
|
||||
def api_bind_repo():
|
||||
global registry_config
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "repo_login", "", "", "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "repo_login", "", "", "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可登录镜像仓库"})
|
||||
try:
|
||||
data = request.get_json()
|
||||
repo_name = data.get("repo_name","")
|
||||
repo_addr = data.get("repo_addr")
|
||||
repo_user = data.get("repo_user","")
|
||||
repo_pwd = data.get("repo_pwd","")
|
||||
api_cli.login(username=repo_user, password=repo_pwd, registry=repo_addr)
|
||||
registry_config["repo_name"] = repo_name
|
||||
registry_config["repo_addr"] = repo_addr
|
||||
registry_config["repo_user"] = repo_user
|
||||
registry_config["repo_pwd"] = repo_pwd
|
||||
write_audit_log(user, role, "repo_login", repo_addr, "", "success", ip)
|
||||
return jsonify({"code":0,"msg":"镜像仓库登录绑定成功"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "repo_login", repo_addr, "", f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 修复:stats接口online_cpus兜底兼容,无KeyError
|
||||
@app.route("/stats/<cid>")
|
||||
def api_stats(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "read_stats", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
raw = container.stats(stream=False)
|
||||
# CPU兼容兜底,不存在online_cpus自动计算
|
||||
cpu_stats = raw["cpu_stats"]
|
||||
online_cpus = cpu_stats.get("online_cpus", len(cpu_stats["cpu_usage"].get("percpu_usage", [])) or 1)
|
||||
cpu_delta = cpu_stats["cpu_usage"]["total_usage"] - raw["precpu_stats"]["cpu_usage"]["total_usage"]
|
||||
system_delta = cpu_stats["system_cpu_usage"] - raw["precpu_stats"]["system_cpu_usage"]
|
||||
cpu_percent = 0.0
|
||||
if system_delta > 0 and cpu_delta > 0:
|
||||
cpu_percent = round((cpu_delta / system_delta) * online_cpus * 100, 1)
|
||||
# 内存计算
|
||||
mem_usage = raw["memory_stats"]["usage"] / 1024 / 1024
|
||||
mem_limit = raw["memory_stats"]["limit"] / 1024 / 1024
|
||||
mem_percent = round((mem_usage / mem_limit) * 100, 1) if mem_limit > 0 else 0
|
||||
# 网络流量
|
||||
net_rx = 0
|
||||
net_tx = 0
|
||||
for net in raw["networks"].values():
|
||||
net_rx += net["rx_bytes"] / 1024 / 1024
|
||||
net_tx += net["tx_bytes"] / 1024 / 1024
|
||||
res = {
|
||||
"cpu": cpu_percent,
|
||||
"mem_mb": round(mem_usage, 1),
|
||||
"mem_limit_mb": round(mem_limit, 1),
|
||||
"mem_percent": mem_percent,
|
||||
"net_rx_mb": round(net_rx, 1),
|
||||
"net_tx_mb": round(net_tx, 1)
|
||||
}
|
||||
write_audit_log(user, role, "read_stats", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":res})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "read_stats", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":{}})
|
||||
|
||||
# 修复:detail接口ENV脱敏,多网卡IP兼容
|
||||
@app.route("/detail/<cid>")
|
||||
def api_detail(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "read_detail", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
attrs = container.attrs
|
||||
nets = attrs["NetworkSettings"]["Networks"]
|
||||
ip_map = {}
|
||||
for net_name, net_info in nets.items():
|
||||
ip_map[net_name] = net_info.get("IPAddress", "")
|
||||
# 敏感环境变量脱敏
|
||||
raw_env = attrs["Config"].get("Env", [])
|
||||
safe_env = safe_env_filter(raw_env)
|
||||
res = {
|
||||
"network_ips": ip_map,
|
||||
"mounts": attrs["Mounts"],
|
||||
"env": safe_env,
|
||||
"created": attrs["Created"],
|
||||
"entrypoint": attrs["Config"]["Entrypoint"],
|
||||
"cmd": attrs["Config"]["Cmd"],
|
||||
"restart_policy": attrs["HostConfig"]["RestartPolicy"]
|
||||
}
|
||||
write_audit_log(user, role, "read_detail", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":res})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "read_detail", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":{}})
|
||||
|
||||
# 根路由
|
||||
@app.route("/")
|
||||
def index():
|
||||
return jsonify({"code":400,"msg":"无效操作","list":[],"data":""})
|
||||
|
||||
# ===================== WebSocket 连接鉴权事件(新增) =====================
|
||||
@socketio.on("connect")
|
||||
def handle_connect(auth):
|
||||
user, role, _, auth_ok = verify_socket_auth(auth)
|
||||
if not auth_ok:
|
||||
return False
|
||||
ws_user_map[request.sid] = {
|
||||
"user": user,
|
||||
"role": role
|
||||
}
|
||||
print("WS Connected:", user, role)
|
||||
|
||||
# ===================== WebSocket 交互式TTY实时终端 =====================
|
||||
def stream_terminal_output(sid, sock):
|
||||
"""后台异步读取docker终端输出,兼容两种socket接收方式"""
|
||||
try:
|
||||
while True:
|
||||
chunk = docker_recv(sock, 1024)
|
||||
if not chunk:
|
||||
break
|
||||
text = chunk.decode("utf-8", errors="replace")
|
||||
socketio.emit("terminal_output", {"data": text}, room=sid)
|
||||
except Exception:
|
||||
pass
|
||||
# 流结束自动清理会话
|
||||
if sid in session_exec_map:
|
||||
try:
|
||||
session_exec_map[sid]["socket"].close()
|
||||
except Exception:
|
||||
pass
|
||||
del session_exec_map[sid]
|
||||
|
||||
@socketio.on("terminal_start")
|
||||
def handle_terminal_start(json_data):
|
||||
sid = request.sid
|
||||
# 新增调试输出
|
||||
socketio.emit(
|
||||
"terminal_output",
|
||||
{
|
||||
"data":"[DEBUG] terminal_start reached\r\n"
|
||||
},
|
||||
room=sid
|
||||
)
|
||||
# 替换原verify_auth_sign(),从ws_user_map读取身份
|
||||
info = ws_user_map.get(request.sid)
|
||||
if not info:
|
||||
socketio.emit(
|
||||
"terminal_output",
|
||||
{"data":"身份认证失效\n"},
|
||||
room=request.sid
|
||||
)
|
||||
return
|
||||
user = info["user"]
|
||||
role = info["role"]
|
||||
client_ip = request.remote_addr
|
||||
|
||||
# 权限拦截:非管理员禁止打开交互式终端
|
||||
if role != "admin":
|
||||
socketio.emit("terminal_output", {"data": "Permission denied: 仅管理员可访问交互式终端\n"}, room=sid)
|
||||
write_audit_log(user, role, "terminal_start", "", json_data.get("cid"), "denied", client_ip)
|
||||
return
|
||||
|
||||
cid = json_data.get("cid")
|
||||
if not cid:
|
||||
socketio.emit("terminal_output", {"data": "错误:容器ID不能为空\n"}, room=sid)
|
||||
write_audit_log(user, role, "terminal_start", "", "", "empty_cid", client_ip)
|
||||
return
|
||||
|
||||
# 自动探测容器可用shell,兼容全版本docker-py(exec_create返回dict取Id)
|
||||
target_shell = None
|
||||
for shell in SHELL_CANDIDATES:
|
||||
try:
|
||||
ret = api_cli.exec_create(
|
||||
container=cid,
|
||||
cmd=[shell, "-c", "echo ok"]
|
||||
)
|
||||
test_exec_id = ret["Id"]
|
||||
test_stream = api_cli.exec_start(test_exec_id)
|
||||
test_stream.read()
|
||||
target_shell = shell
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not target_shell:
|
||||
socketio.emit("terminal_output", {"data": "错误:容器内无可用shell(bash/sh/ash)\n"}, room=sid)
|
||||
write_audit_log(user, role, "terminal_start", "", cid, "no_shell", client_ip)
|
||||
return
|
||||
|
||||
try:
|
||||
# 创建交互式TTY会话,统一提取Id兼容所有SDK版本
|
||||
exec_ret = api_cli.exec_create(
|
||||
container=cid,
|
||||
cmd=target_shell,
|
||||
stdin=True,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
tty=True
|
||||
)
|
||||
exec_id = exec_ret["Id"]
|
||||
sock = api_cli.exec_start(exec_id=exec_id, tty=True, socket=True, detach=False)
|
||||
session_exec_map[sid] = {
|
||||
"cid": cid,
|
||||
"exec_id": exec_id,
|
||||
"socket": sock
|
||||
}
|
||||
socketio.start_background_task(stream_terminal_output, sid, sock)
|
||||
container_name = cli.containers.get(cid).name
|
||||
write_audit_log(user, role, "terminal_start", container_name, cid, "success", client_ip)
|
||||
except Exception as e:
|
||||
socketio.emit("terminal_output", {"data": f"启动终端失败:{str(e)}\n"}, room=sid)
|
||||
write_audit_log(user, role, "terminal_start", "", cid, f"fail:{str(e)}", client_ip)
|
||||
|
||||
@socketio.on("terminal_input")
|
||||
def handle_terminal_input(json_data):
|
||||
"""前端输入字符写入容器标准输入,审计脱敏不记录明文"""
|
||||
sid = request.sid
|
||||
# 替换原verify_auth_sign()
|
||||
info = ws_user_map.get(request.sid)
|
||||
if not info:
|
||||
return
|
||||
user = info["user"]
|
||||
role = info["role"]
|
||||
client_ip = request.remote_addr
|
||||
|
||||
if sid not in session_exec_map:
|
||||
socketio.emit("terminal_output", {"data": "终端会话已断开,请重新打开\n"}, room=sid)
|
||||
return
|
||||
input_text = json_data.get("data", "")
|
||||
sock = session_exec_map[sid]["socket"]
|
||||
sock.send(input_text.encode("utf-8"))
|
||||
cid = session_exec_map[sid]["cid"]
|
||||
# 审计仅标记事件,不再记录明文命令,规避密码泄露
|
||||
write_audit_log(user, role, "terminal_input", "", cid, "command_entered", client_ip)
|
||||
|
||||
@socketio.on("terminal_resize")
|
||||
def handle_terminal_resize(json_data):
|
||||
"""前端xterm窗口大小自适应"""
|
||||
sid = request.sid
|
||||
if sid not in session_exec_map:
|
||||
return
|
||||
exec_id = session_exec_map[sid]["exec_id"]
|
||||
height = int(json_data.get("height", 24))
|
||||
width = int(json_data.get("width", 80))
|
||||
try:
|
||||
api_cli.exec_resize(exec_id=exec_id, height=height, width=width)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 新增主动关闭终端事件(解决切换容器残留TTY)
|
||||
@socketio.on("terminal_close")
|
||||
def handle_terminal_close():
|
||||
sid = request.sid
|
||||
info = ws_user_map.get(request.sid)
|
||||
if not info:
|
||||
return
|
||||
user = info["user"]
|
||||
role = info["role"]
|
||||
client_ip = request.remote_addr
|
||||
|
||||
if sid in session_exec_map:
|
||||
cid = session_exec_map[sid]["cid"]
|
||||
try:
|
||||
session_exec_map[sid]["socket"].close()
|
||||
except Exception:
|
||||
pass
|
||||
del session_exec_map[sid]
|
||||
write_audit_log(user, role, "terminal_close", "", cid, "manual_close", client_ip)
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def handle_disconnect():
|
||||
"""前端断开连接,释放docker socket、清理会话,防止句柄泄漏"""
|
||||
sid = request.sid
|
||||
# 清理ws_user_map会话
|
||||
if sid in ws_user_map:
|
||||
del ws_user_map[sid]
|
||||
info = ws_user_map.get(sid)
|
||||
if not info:
|
||||
user = ""
|
||||
role = ""
|
||||
client_ip = request.remote_addr
|
||||
else:
|
||||
user = info["user"]
|
||||
role = info["role"]
|
||||
client_ip = request.remote_addr
|
||||
|
||||
# 清理TTY终端
|
||||
if sid in session_exec_map:
|
||||
cid = session_exec_map[sid]["cid"]
|
||||
try:
|
||||
session_exec_map[sid]["socket"].close()
|
||||
except Exception:
|
||||
pass
|
||||
del session_exec_map[sid]
|
||||
write_audit_log(user, role, "terminal_close", "", cid, "disconnect", client_ip)
|
||||
# 清理实时日志标记,终止日志循环
|
||||
if sid in log_task_map:
|
||||
del log_task_map[sid]
|
||||
|
||||
# ===================== WebSocket 实时流式日志 docker logs -f(生成器主动释放) =====================
|
||||
def stream_follow_log(sid, cid):
|
||||
generator = None
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
generator = container.logs(stream=True, follow=True)
|
||||
log_task_map[sid] = {"cid": cid, "running": True}
|
||||
for line in generator:
|
||||
task_info = log_task_map.get(sid, {})
|
||||
if not task_info.get("running"):
|
||||
break
|
||||
txt = line.decode("utf-8", errors="replace")
|
||||
socketio.emit("log_output", {"data": txt}, room=sid)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# 主动关闭日志生成器,释放docker daemon长连接
|
||||
if generator is not None:
|
||||
generator.close()
|
||||
if sid in log_task_map:
|
||||
del log_task_map[sid]
|
||||
|
||||
@socketio.on("log_start")
|
||||
def handle_log_start(json_data):
|
||||
sid = request.sid
|
||||
# 替换verify_auth_sign,读取ws_user_map
|
||||
info = ws_user_map.get(request.sid)
|
||||
if not info:
|
||||
socketio.emit("log_output", {"data": "身份校验失败"}, room=sid)
|
||||
write_audit_log("", "", "log_start", "", "", "auth_failed", request.remote_addr)
|
||||
return
|
||||
user = info["user"]
|
||||
role = info["role"]
|
||||
client_ip = request.remote_addr
|
||||
|
||||
cid = json_data.get("cid")
|
||||
if not cid:
|
||||
socketio.emit("log_output", {"data": "容器ID不能为空"}, room=sid)
|
||||
write_audit_log(user, role, "log_start", "", "", "empty_cid", client_ip)
|
||||
return
|
||||
if role != "admin":
|
||||
socketio.emit("log_output", {"data": "权限不足,仅管理员可查看实时日志"}, room=sid)
|
||||
write_audit_log(user, role, "log_start", "", cid, "denied", client_ip)
|
||||
return
|
||||
socketio.start_background_task(stream_follow_log, sid, cid)
|
||||
container_name = cli.containers.get(cid).name
|
||||
write_audit_log(user, role, "log_start", container_name, cid, "success", client_ip)
|
||||
|
||||
@socketio.on("log_stop")
|
||||
def handle_log_stop():
|
||||
sid = request.sid
|
||||
info = ws_user_map.get(request.sid)
|
||||
if not info:
|
||||
return
|
||||
user = info["user"]
|
||||
role = info["role"]
|
||||
client_ip = request.remote_addr
|
||||
|
||||
if sid in log_task_map:
|
||||
log_task_map[sid]["running"] = False
|
||||
cid = log_task_map[sid]["cid"]
|
||||
write_audit_log(user, role, "log_stop", "", cid, "manual_close", client_ip)
|
||||
|
||||
# 启动入口(eventlet异步,必须socketio.run)
|
||||
if __name__ == "__main__":
|
||||
socketio.run(app, host="0.0.0.0", port=8090, debug=False, allow_unsafe_werkzeug=True)
|
||||
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
import docker
|
||||
from flask import Flask, jsonify
|
||||
|
||||
# 兼容新旧版本
|
||||
try:
|
||||
cli = docker.DockerClient(base_url='unix:///var/run/docker.sock')
|
||||
except AttributeError:
|
||||
cli = docker.Client(base_url='unix:///var/run/docker.sock')
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# 容器列表
|
||||
@app.route("/list")
|
||||
def api_list():
|
||||
arr = []
|
||||
for c in cli.containers.list(all=True):
|
||||
img_name = c.image.tags[0] if c.image.tags else ""
|
||||
arr.append({
|
||||
"id": c.short_id,
|
||||
"full_id": c.id,
|
||||
"name": c.name,
|
||||
"image": img_name,
|
||||
"status": c.status,
|
||||
"ports": str(c.ports)
|
||||
})
|
||||
return jsonify({"code":0,"msg":"success","list":arr})
|
||||
|
||||
# 镜像列表
|
||||
@app.route("/image_list")
|
||||
def api_image_list():
|
||||
arr = []
|
||||
for img in cli.images.list():
|
||||
if not img.tags:
|
||||
continue
|
||||
full_tag = img.tags[0]
|
||||
repo, tag = full_tag.split(":", 1)
|
||||
size_mb = round(img.attrs["Size"] / 1024 / 1024, 1)
|
||||
arr.append({
|
||||
"repo": repo,
|
||||
"tag": tag,
|
||||
"id": img.short_id,
|
||||
"full_id": img.id,
|
||||
"size": f"{size_mb}MB",
|
||||
"created": img.attrs["Created"]
|
||||
})
|
||||
return jsonify({"code":0,"msg":"success","list":arr})
|
||||
|
||||
# 重启容器
|
||||
@app.route("/restart/<cid>")
|
||||
def api_restart(cid):
|
||||
try:
|
||||
cli.containers.get(cid).restart()
|
||||
return jsonify({"code":0,"msg":"容器已重启"})
|
||||
except Exception as e:
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 停止容器
|
||||
@app.route("/stop/<cid>")
|
||||
def api_stop(cid):
|
||||
try:
|
||||
cli.containers.get(cid).stop()
|
||||
return jsonify({"code":0,"msg":"容器已停止"})
|
||||
except Exception as e:
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 删除容器
|
||||
@app.route("/rm/<cid>")
|
||||
def api_rm(cid):
|
||||
try:
|
||||
cli.containers.get(cid).remove(force=True)
|
||||
return jsonify({"code":0,"msg":"容器已强制删除"})
|
||||
except Exception as e:
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 容器日志
|
||||
@app.route("/logs/<cid>/<int:tail_lines>")
|
||||
def api_logs(cid, tail_lines):
|
||||
try:
|
||||
log_bytes = cli.containers.get(cid).logs(tail=tail_lines)
|
||||
log_str = log_bytes.decode("utf-8", errors="ignore")
|
||||
return jsonify({"code":0,"msg":"success","data":log_str})
|
||||
except Exception as e:
|
||||
return jsonify({"code":500,"msg":str(e),"data":""})
|
||||
|
||||
# 网络信息
|
||||
@app.route("/network/<cid>")
|
||||
def api_network(cid):
|
||||
try:
|
||||
info = cli.containers.get(cid).attrs["NetworkSettings"]["Networks"]
|
||||
return jsonify({"code":0,"msg":"success","data":info})
|
||||
except Exception as e:
|
||||
return jsonify({"code":500,"msg":str(e),"data":""})
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return jsonify({"code":400,"msg":"无效操作","list":[],"data":""})
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8090, debug=False)
|
||||
@@ -0,0 +1,669 @@
|
||||
# 【强制置顶】eventlet猴子补丁,必须放在所有导入最前面
|
||||
import json
|
||||
import shlex
|
||||
import time
|
||||
import hashlib
|
||||
import hmac
|
||||
import docker
|
||||
from flask import Flask, jsonify, request
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
# 全局签名密钥,前后端统一,生产环境使用环境变量注入
|
||||
SIGN_SECRET = b"DockerAdminPlatform2026SecretKey123456789"
|
||||
# 签名有效期 300秒(5分钟)
|
||||
SIGN_EXPIRE = 300
|
||||
|
||||
# 兼容新旧docker-py客户端
|
||||
try:
|
||||
cli = docker.DockerClient(base_url='unix:///var/run/docker.sock')
|
||||
api_cli = docker.APIClient(base_url='unix:///var/run/docker.sock')
|
||||
except AttributeError:
|
||||
cli = docker.Client(base_url='unix:///var/run/docker.sock')
|
||||
api_cli = docker.APIClient(base_url='unix:///var/run/docker.sock')
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'docker-terminal-secret-2026'
|
||||
|
||||
# WebSocket心跳保活,防止Nginx/反向代理断连
|
||||
socketio = SocketIO(
|
||||
app,
|
||||
cors_allowed_origins="*",
|
||||
async_mode="threading",
|
||||
ping_timeout=60,
|
||||
ping_interval=25
|
||||
)
|
||||
|
||||
# 全局会话存储
|
||||
session_exec_map = {} # sid => TTY终端流信息
|
||||
log_task_map = {} # sid => {"cid": "", "running": True}
|
||||
registry_config = {
|
||||
"repo_name": "",
|
||||
"repo_addr": "",
|
||||
"repo_user": "",
|
||||
"repo_pwd": ""
|
||||
}
|
||||
# 多系统shell兼容列表(Ubuntu/Alpine/标准Linux)
|
||||
SHELL_CANDIDATES = [
|
||||
"/bin/bash",
|
||||
"/usr/bin/bash",
|
||||
"/bin/sh",
|
||||
"/bin/ash"
|
||||
]
|
||||
|
||||
# ===================== 工具函数1:docker socket兼容recv =====================
|
||||
def docker_recv(sock, size=1024):
|
||||
try:
|
||||
return sock.recv(size)
|
||||
except Exception:
|
||||
try:
|
||||
return sock._sock.recv(size)
|
||||
except Exception:
|
||||
return b""
|
||||
|
||||
# ===================== 工具函数2:操作审计日志(终端输入脱敏,不存明文密码) =====================
|
||||
def write_audit_log(user, role, action, target_name, target_id, result, client_ip):
|
||||
"""企业审计记录,可对接数据库docker_audit_log表"""
|
||||
# 终端输入仅标记事件,不记录明文命令,规避密码泄露风险
|
||||
if action == "terminal_input":
|
||||
result = "command_entered"
|
||||
log_item = {
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"user": user,
|
||||
"role": role,
|
||||
"action": action,
|
||||
"container_name": target_name,
|
||||
"container_id": target_id,
|
||||
"result": result,
|
||||
"client_ip": client_ip
|
||||
}
|
||||
# 生产环境替换为mysql insert语句
|
||||
print(json.dumps(log_item, ensure_ascii=False))
|
||||
|
||||
# ===================== 工具函数3:HMAC签名鉴权,防Header伪造 =====================
|
||||
def verify_auth_sign():
|
||||
"""
|
||||
校验前端PHP生成的HMAC签名,防止伪造X-Role:admin
|
||||
请求Header要求:
|
||||
X-User: 用户名
|
||||
X-Role: admin/user/guest
|
||||
X-Time: 当前时间戳(秒)
|
||||
X-Sign: hmac-sha256签名
|
||||
返回 (user, role, client_ip, True/False)
|
||||
"""
|
||||
user = request.headers.get("X-User", "anonymous")
|
||||
role = request.headers.get("X-Role", "guest")
|
||||
ts_str = request.headers.get("X-Time", "0")
|
||||
sign = request.headers.get("X-Sign", "")
|
||||
client_ip = request.headers.get("X-Client-Ip", request.remote_addr)
|
||||
try:
|
||||
ts = int(ts_str)
|
||||
except ValueError:
|
||||
return user, role, client_ip, False
|
||||
# 校验签名有效期
|
||||
now = int(time.time())
|
||||
if abs(now - ts) > SIGN_EXPIRE:
|
||||
return user, role, client_ip, False
|
||||
# 校验HMAC签名
|
||||
raw_data = f"{user}|{role}|{ts}".encode("utf-8")
|
||||
calc_sign = hmac.new(SIGN_SECRET, raw_data, hashlib.sha256).hexdigest()
|
||||
if calc_sign != sign:
|
||||
return user, role, client_ip, False
|
||||
return user, role, client_ip, True
|
||||
|
||||
# ===================== 工具函数4:环境变量脱敏,屏蔽密码/密钥 =====================
|
||||
def safe_env_filter(env_list):
|
||||
safe_env = []
|
||||
sensitive_keywords = ["PASS", "PASSWORD", "TOKEN", "SECRET", "KEY", "CREDENTIAL"]
|
||||
for item in env_list:
|
||||
equal_pos = item.find("=")
|
||||
if equal_pos == -1:
|
||||
safe_env.append(item)
|
||||
continue
|
||||
key = item[:equal_pos]
|
||||
val = item[equal_pos+1:]
|
||||
if any(word in key.upper() for word in sensitive_keywords):
|
||||
safe_env.append(f"{key}=******")
|
||||
else:
|
||||
safe_env.append(item)
|
||||
return safe_env
|
||||
|
||||
# ===================== 全部HTTP业务接口 =====================
|
||||
# 1. 容器列表(端口预拼接字符串,前端直接渲染,无需formatPorts)
|
||||
@app.route("/list")
|
||||
def api_list():
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "list_container", "", "", "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
arr = []
|
||||
for c in cli.containers.list(all=True):
|
||||
img_name = c.image.tags[0] if c.image.tags else ""
|
||||
port_str_arr = []
|
||||
ports_raw = c.ports
|
||||
for c_port, host_list in ports_raw.items():
|
||||
if not host_list:
|
||||
continue
|
||||
for host_item in host_list:
|
||||
host_port = host_item.get("HostPort", "")
|
||||
port_str_arr.append(f"{host_port}:{c_port.split('/')[0]}")
|
||||
port_display = ",".join(port_str_arr) if port_str_arr else "-"
|
||||
arr.append({
|
||||
"id": c.short_id,
|
||||
"full_id": c.id,
|
||||
"name": c.name,
|
||||
"image": img_name,
|
||||
"status": c.status,
|
||||
"ports": port_display
|
||||
})
|
||||
write_audit_log(user, role, "list_container", "", "", "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","list":arr})
|
||||
|
||||
# 2. 镜像列表
|
||||
@app.route("/image_list")
|
||||
def api_image_list():
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "list_image", "", "", "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
arr = []
|
||||
for img in cli.images.list():
|
||||
if not img.tags:
|
||||
continue
|
||||
full_tag = img.tags[0]
|
||||
repo, tag = full_tag.split(":", 1)
|
||||
size_mb = round(img.attrs["Size"] / 1024 / 1024, 1)
|
||||
arr.append({
|
||||
"repo": repo,
|
||||
"tag": tag,
|
||||
"id": img.short_id,
|
||||
"full_id": img.id,
|
||||
"size": f"{size_mb}MB",
|
||||
"created": img.attrs["Created"]
|
||||
})
|
||||
write_audit_log(user, role, "list_image", "", "", "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","list":arr})
|
||||
|
||||
# 3. 删除镜像
|
||||
@app.route("/remove_image/<image_id>")
|
||||
def api_remove_image(image_id):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "remove_image", "", image_id, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "remove_image", "", image_id, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可删除镜像"})
|
||||
try:
|
||||
img = cli.images.get(image_id)
|
||||
cli.images.remove(image_id, force=True)
|
||||
write_audit_log(user, role, "remove_image", img.tags[0] if img.tags else "", image_id, "success", ip)
|
||||
return jsonify({"code":0,"msg":"镜像已强制删除"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "remove_image", "", image_id, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 4. 重启容器
|
||||
@app.route("/restart/<cid>")
|
||||
def api_restart(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "restart", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "restart", "", cid, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可操作容器"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
container.restart()
|
||||
write_audit_log(user, role, "restart", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"容器已重启"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "restart", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 5. 停止容器
|
||||
@app.route("/stop/<cid>")
|
||||
def api_stop(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "stop", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "stop", "", cid, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可操作容器"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
container.stop()
|
||||
write_audit_log(user, role, "stop", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"容器已停止"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "stop", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 6. 删除容器
|
||||
@app.route("/rm/<cid>")
|
||||
def api_rm(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "rm", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "rm", "", cid, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可删除容器"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
container.remove(force=True)
|
||||
write_audit_log(user, role, "rm", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"容器已强制删除"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "rm", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 7. 一次性拉取日志(兼容旧页面单次查看)
|
||||
@app.route("/logs/<cid>/<int:tail_lines>")
|
||||
def api_logs(cid, tail_lines):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "read_log", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
log_bytes = container.logs(tail=tail_lines)
|
||||
log_str = log_bytes.decode("utf-8", errors="replace")
|
||||
write_audit_log(user, role, "read_log", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":log_str})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "read_log", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":""})
|
||||
|
||||
# 8. 容器网络详情
|
||||
@app.route("/network/<cid>")
|
||||
def api_network(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "read_network", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
info = container.attrs["NetworkSettings"]["Networks"]
|
||||
write_audit_log(user, role, "read_network", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":json.dumps(info,indent=2)})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "read_network", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":""})
|
||||
|
||||
# 9. 短连接单次执行命令(旧弹窗兼容,非实时终端)
|
||||
@app.route("/exec/<cid>", methods=["POST"])
|
||||
def api_exec(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "single_exec", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "single_exec", "", cid, "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可执行命令"})
|
||||
try:
|
||||
data = request.get_json()
|
||||
cmd = data.get("cmd", "")
|
||||
if not cmd:
|
||||
return jsonify({"code":400,"msg":"命令不能为空","data":""})
|
||||
cmd_list = shlex.split(cmd)
|
||||
container = cli.containers.get(cid)
|
||||
exec_obj = container.exec_run(cmd_list, stdout=True, stderr=True)
|
||||
output = exec_obj.output.decode("utf-8", errors="replace")
|
||||
write_audit_log(user, role, "single_exec", container.name, cid, "command_executed", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":output})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "single_exec", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":""})
|
||||
|
||||
# 10. 创建容器(端口标准格式 + 前端可配置CPU/内存)
|
||||
@app.route("/create", methods=["POST"])
|
||||
def api_create():
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "create_container", "", "", "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "create_container", "", "", "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可创建容器"})
|
||||
try:
|
||||
data = request.get_json()
|
||||
name = data.get("name", "")
|
||||
image = data.get("image")
|
||||
cmd = data.get("cmd")
|
||||
ports_input = data.get("ports", {})
|
||||
# 修复:docker标准端口字典格式 {内部端口:宿主机端口}
|
||||
port_bind = {}
|
||||
for inner_port, host_port in ports_input.items():
|
||||
port_bind[f"{inner_port}/tcp"] = int(host_port)
|
||||
# 前端可配置资源限制,默认1核512M
|
||||
cpu_core = int(data.get("cpu", 1))
|
||||
memory_limit = data.get("memory", "512m")
|
||||
nano_cpus = cpu_core * 1000000000
|
||||
container = cli.containers.run(
|
||||
image=image,
|
||||
name=name if name else None,
|
||||
command=cmd if cmd else None,
|
||||
ports=port_bind,
|
||||
detach=True,
|
||||
restart_policy={"Name": "always"},
|
||||
mem_limit=memory_limit,
|
||||
nano_cpus=nano_cpus
|
||||
)
|
||||
write_audit_log(user, role, "create_container", container.name, container.id, "success", ip)
|
||||
return jsonify({"code":0,"msg":f"容器创建成功,ID:{container.short_id}"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "create_container", "", "", f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 11. 登录私有镜像仓库
|
||||
@app.route("/bind_repo", methods=["POST"])
|
||||
def api_bind_repo():
|
||||
global registry_config
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "repo_login", "", "", "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
if role != "admin":
|
||||
write_audit_log(user, role, "repo_login", "", "", "denied", ip)
|
||||
return jsonify({"code":403,"msg":"权限不足,仅管理员可登录镜像仓库"})
|
||||
try:
|
||||
data = request.get_json()
|
||||
repo_name = data.get("repo_name","")
|
||||
repo_addr = data.get("repo_addr")
|
||||
repo_user = data.get("repo_user","")
|
||||
repo_pwd = data.get("repo_pwd","")
|
||||
api_cli.login(username=repo_user, password=repo_pwd, registry=repo_addr)
|
||||
registry_config["repo_name"] = repo_name
|
||||
registry_config["repo_addr"] = repo_addr
|
||||
registry_config["repo_user"] = repo_user
|
||||
registry_config["repo_pwd"] = repo_pwd
|
||||
write_audit_log(user, role, "repo_login", repo_addr, "", "success", ip)
|
||||
return jsonify({"code":0,"msg":"镜像仓库登录绑定成功"})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "repo_login", repo_addr, "", f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e)})
|
||||
|
||||
# 修复:stats接口online_cpus兜底兼容,无KeyError
|
||||
@app.route("/stats/<cid>")
|
||||
def api_stats(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "read_stats", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
raw = container.stats(stream=False)
|
||||
# CPU兼容兜底,不存在online_cpus自动计算
|
||||
cpu_stats = raw["cpu_stats"]
|
||||
online_cpus = cpu_stats.get("online_cpus", len(cpu_stats["cpu_usage"].get("percpu_usage", [])) or 1)
|
||||
cpu_delta = cpu_stats["cpu_usage"]["total_usage"] - raw["precpu_stats"]["cpu_usage"]["total_usage"]
|
||||
system_delta = cpu_stats["system_cpu_usage"] - raw["precpu_stats"]["system_cpu_usage"]
|
||||
cpu_percent = 0.0
|
||||
if system_delta > 0 and cpu_delta > 0:
|
||||
cpu_percent = round((cpu_delta / system_delta) * online_cpus * 100, 1)
|
||||
# 内存计算
|
||||
mem_usage = raw["memory_stats"]["usage"] / 1024 / 1024
|
||||
mem_limit = raw["memory_stats"]["limit"] / 1024 / 1024
|
||||
mem_percent = round((mem_usage / mem_limit) * 100, 1) if mem_limit > 0 else 0
|
||||
# 网络流量
|
||||
net_rx = 0
|
||||
net_tx = 0
|
||||
for net in raw["networks"].values():
|
||||
net_rx += net["rx_bytes"] / 1024 / 1024
|
||||
net_tx += net["tx_bytes"] / 1024 / 1024
|
||||
res = {
|
||||
"cpu": cpu_percent,
|
||||
"mem_mb": round(mem_usage, 1),
|
||||
"mem_limit_mb": round(mem_limit, 1),
|
||||
"mem_percent": mem_percent,
|
||||
"net_rx_mb": round(net_rx, 1),
|
||||
"net_tx_mb": round(net_tx, 1)
|
||||
}
|
||||
write_audit_log(user, role, "read_stats", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":res})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "read_stats", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":{}})
|
||||
|
||||
# 修复:detail接口ENV脱敏,多网卡IP兼容
|
||||
@app.route("/detail/<cid>")
|
||||
def api_detail(cid):
|
||||
user, role, ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
write_audit_log(user, role, "read_detail", "", cid, "auth_failed", ip)
|
||||
return jsonify({"code":403,"msg":"身份校验失败,非法访问"})
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
attrs = container.attrs
|
||||
nets = attrs["NetworkSettings"]["Networks"]
|
||||
ip_map = {}
|
||||
for net_name, net_info in nets.items():
|
||||
ip_map[net_name] = net_info.get("IPAddress", "")
|
||||
# 敏感环境变量脱敏
|
||||
raw_env = attrs["Config"].get("Env", [])
|
||||
safe_env = safe_env_filter(raw_env)
|
||||
res = {
|
||||
"network_ips": ip_map,
|
||||
"mounts": attrs["Mounts"],
|
||||
"env": safe_env,
|
||||
"created": attrs["Created"],
|
||||
"entrypoint": attrs["Config"]["Entrypoint"],
|
||||
"cmd": attrs["Config"]["Cmd"],
|
||||
"restart_policy": attrs["HostConfig"]["RestartPolicy"]
|
||||
}
|
||||
write_audit_log(user, role, "read_detail", container.name, cid, "success", ip)
|
||||
return jsonify({"code":0,"msg":"success","data":res})
|
||||
except Exception as e:
|
||||
write_audit_log(user, role, "read_detail", "", cid, f"fail:{str(e)}", ip)
|
||||
return jsonify({"code":500,"msg":str(e),"data":{}})
|
||||
|
||||
# 根路由
|
||||
@app.route("/")
|
||||
def index():
|
||||
return jsonify({"code":400,"msg":"无效操作","list":[],"data":""})
|
||||
|
||||
# ===================== WebSocket 交互式TTY实时终端 =====================
|
||||
def stream_terminal_output(sid, sock):
|
||||
"""后台异步读取docker终端输出,兼容两种socket接收方式"""
|
||||
try:
|
||||
while True:
|
||||
chunk = docker_recv(sock, 1024)
|
||||
if not chunk:
|
||||
break
|
||||
text = chunk.decode("utf-8", errors="replace")
|
||||
socketio.emit("terminal_output", {"data": text}, room=sid)
|
||||
except Exception:
|
||||
pass
|
||||
# 流结束自动清理会话
|
||||
if sid in session_exec_map:
|
||||
try:
|
||||
session_exec_map[sid]["socket"].close()
|
||||
except Exception:
|
||||
pass
|
||||
del session_exec_map[sid]
|
||||
|
||||
@socketio.on("terminal_start")
|
||||
def handle_terminal_start(json_data):
|
||||
sid = request.sid
|
||||
user, role, client_ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
socketio.emit("terminal_output", {"data": "Permission denied: 身份校验失败\n"}, room=sid)
|
||||
write_audit_log(user, role, "terminal_start", "", json_data.get("cid"), "auth_failed", client_ip)
|
||||
return
|
||||
# 权限拦截:非管理员禁止打开交互式终端
|
||||
if role != "admin":
|
||||
socketio.emit("terminal_output", {"data": "Permission denied: 仅管理员可访问交互式终端\n"}, room=sid)
|
||||
write_audit_log(user, role, "terminal_start", "", json_data.get("cid"), "denied", client_ip)
|
||||
return
|
||||
|
||||
cid = json_data.get("cid")
|
||||
if not cid:
|
||||
socketio.emit("terminal_output", {"data": "错误:容器ID不能为空\n"}, room=sid)
|
||||
write_audit_log(user, role, "terminal_start", "", "", "empty_cid", client_ip)
|
||||
return
|
||||
|
||||
# 自动探测容器可用shell,兼容全版本docker-py(exec_create返回dict取Id)
|
||||
target_shell = None
|
||||
for shell in SHELL_CANDIDATES:
|
||||
try:
|
||||
ret = api_cli.exec_create(
|
||||
container=cid,
|
||||
cmd=[shell, "-c", "echo ok"]
|
||||
)
|
||||
test_exec_id = ret["Id"]
|
||||
test_stream = api_cli.exec_start(test_exec_id)
|
||||
test_stream.read()
|
||||
target_shell = shell
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not target_shell:
|
||||
socketio.emit("terminal_output", {"data": "错误:容器内无可用shell(bash/sh/ash)\n"}, room=sid)
|
||||
write_audit_log(user, role, "terminal_start", "", cid, "no_shell", client_ip)
|
||||
return
|
||||
|
||||
try:
|
||||
# 创建交互式TTY会话,统一提取Id兼容所有SDK版本
|
||||
exec_ret = api_cli.exec_create(
|
||||
container=cid,
|
||||
cmd=target_shell,
|
||||
stdin=True,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
tty=True
|
||||
)
|
||||
exec_id = exec_ret["Id"]
|
||||
sock = api_cli.exec_start(exec_id=exec_id, tty=True, socket=True, detach=False)
|
||||
session_exec_map[sid] = {
|
||||
"cid": cid,
|
||||
"exec_id": exec_id,
|
||||
"socket": sock
|
||||
}
|
||||
socketio.start_background_task(stream_terminal_output, sid, sock)
|
||||
container_name = cli.containers.get(cid).name
|
||||
write_audit_log(user, role, "terminal_start", container_name, cid, "success", client_ip)
|
||||
except Exception as e:
|
||||
socketio.emit("terminal_output", {"data": f"启动终端失败:{str(e)}\n"}, room=sid)
|
||||
write_audit_log(user, role, "terminal_start", "", cid, f"fail:{str(e)}", client_ip)
|
||||
|
||||
@socketio.on("terminal_input")
|
||||
def handle_terminal_input(json_data):
|
||||
"""前端输入字符写入容器标准输入,审计脱敏不记录明文"""
|
||||
sid = request.sid
|
||||
user, role, client_ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
socketio.emit("terminal_output", {"data": "身份校验失败\n"}, room=sid)
|
||||
return
|
||||
if sid not in session_exec_map:
|
||||
socketio.emit("terminal_output", {"data": "终端会话已断开,请重新打开\n"}, room=sid)
|
||||
return
|
||||
input_text = json_data.get("data", "")
|
||||
sock = session_exec_map[sid]["socket"]
|
||||
sock.send(input_text.encode("utf-8"))
|
||||
cid = session_exec_map[sid]["cid"]
|
||||
# 审计仅标记事件,不再记录明文命令,规避密码泄露
|
||||
write_audit_log(user, role, "terminal_input", "", cid, "command_entered", client_ip)
|
||||
|
||||
@socketio.on("terminal_resize")
|
||||
def handle_terminal_resize(json_data):
|
||||
"""前端xterm窗口大小自适应"""
|
||||
sid = request.sid
|
||||
if sid not in session_exec_map:
|
||||
return
|
||||
exec_id = session_exec_map[sid]["exec_id"]
|
||||
height = int(json_data.get("height", 24))
|
||||
width = int(json_data.get("width", 80))
|
||||
try:
|
||||
api_cli.exec_resize(exec_id=exec_id, height=height, width=width)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 新增主动关闭终端事件(解决切换容器残留TTY)
|
||||
@socketio.on("terminal_close")
|
||||
def handle_terminal_close():
|
||||
sid = request.sid
|
||||
user, role, client_ip, auth_ok = verify_auth_sign()
|
||||
if sid in session_exec_map:
|
||||
cid = session_exec_map[sid]["cid"]
|
||||
try:
|
||||
session_exec_map[sid]["socket"].close()
|
||||
except Exception:
|
||||
pass
|
||||
del session_exec_map[sid]
|
||||
write_audit_log(user, role, "terminal_close", "", cid, "manual_close", client_ip)
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def handle_disconnect():
|
||||
"""前端断开连接,释放docker socket、清理会话,防止句柄泄漏"""
|
||||
sid = request.sid
|
||||
user, role, client_ip, auth_ok = verify_auth_sign()
|
||||
# 清理TTY终端
|
||||
if sid in session_exec_map:
|
||||
cid = session_exec_map[sid]["cid"]
|
||||
try:
|
||||
session_exec_map[sid]["socket"].close()
|
||||
except Exception:
|
||||
pass
|
||||
del session_exec_map[sid]
|
||||
write_audit_log(user, role, "terminal_close", "", cid, "disconnect", client_ip)
|
||||
# 清理实时日志标记,终止日志循环
|
||||
if sid in log_task_map:
|
||||
del log_task_map[sid]
|
||||
|
||||
# ===================== WebSocket 实时流式日志 docker logs -f(生成器主动释放) =====================
|
||||
def stream_follow_log(sid, cid):
|
||||
generator = None
|
||||
try:
|
||||
container = cli.containers.get(cid)
|
||||
generator = container.logs(stream=True, follow=True)
|
||||
log_task_map[sid] = {"cid": cid, "running": True}
|
||||
for line in generator:
|
||||
task_info = log_task_map.get(sid, {})
|
||||
if not task_info.get("running"):
|
||||
break
|
||||
txt = line.decode("utf-8", errors="replace")
|
||||
socketio.emit("log_output", {"data": txt}, room=sid)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# 主动关闭日志生成器,释放docker daemon长连接
|
||||
if generator is not None:
|
||||
generator.close()
|
||||
if sid in log_task_map:
|
||||
del log_task_map[sid]
|
||||
|
||||
@socketio.on("log_start")
|
||||
def handle_log_start(json_data):
|
||||
sid = request.sid
|
||||
user, role, client_ip, auth_ok = verify_auth_sign()
|
||||
if not auth_ok:
|
||||
socketio.emit("log_output", {"data": "身份校验失败"}, room=sid)
|
||||
write_audit_log(user, role, "log_start", "", "", "auth_failed", client_ip)
|
||||
return
|
||||
cid = json_data.get("cid")
|
||||
if not cid:
|
||||
socketio.emit("log_output", {"data": "容器ID不能为空"}, room=sid)
|
||||
write_audit_log(user, role, "log_start", "", "", "empty_cid", client_ip)
|
||||
return
|
||||
if role != "admin":
|
||||
socketio.emit("log_output", {"data": "权限不足,仅管理员可查看实时日志"}, room=sid)
|
||||
write_audit_log(user, role, "log_start", "", cid, "denied", client_ip)
|
||||
return
|
||||
socketio.start_background_task(stream_follow_log, sid, cid)
|
||||
container_name = cli.containers.get(cid).name
|
||||
write_audit_log(user, role, "log_start", container_name, cid, "success", client_ip)
|
||||
|
||||
@socketio.on("log_stop")
|
||||
def handle_log_stop():
|
||||
sid = request.sid
|
||||
user, role, client_ip, auth_ok = verify_auth_sign()
|
||||
if sid in log_task_map:
|
||||
log_task_map[sid]["running"] = False
|
||||
cid = log_task_map[sid]["cid"]
|
||||
write_audit_log(user, role, "log_stop", "", cid, "manual_close", client_ip)
|
||||
|
||||
# 启动入口(eventlet异步,必须socketio.run)
|
||||
if __name__ == "__main__":
|
||||
socketio.run(app, host="0.0.0.0", port=8090, debug=False, allow_unsafe_werkzeug=True)
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
while true; do
|
||||
nc -l 127.0.0.1 9999 <<EOF
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
$(docker ps -a --format '{{.Names}}|{{.ID}}|{{.Image}}|{{.Status}}|{{.Ports}}')
|
||||
EOF
|
||||
done
|
||||
@@ -25,28 +25,58 @@ function getDbConn() {
|
||||
|
||||
// CSV导出接口(支持指定历史日期下载,不输出JSON头)
|
||||
if ($act === "export_csv") {
|
||||
date_default_timezone_set('Asia/Shanghai');
|
||||
$conn = getDbConn();
|
||||
mysqli_query($conn, "SET time_zone = '+08:00'");
|
||||
if (!$conn) {
|
||||
header("Content-Type:text/html;charset=utf-8");
|
||||
echo "数据库连接失败:" . mysqli_connect_error();
|
||||
exit;
|
||||
}
|
||||
// 接收自定义日期参数,不传则取今日
|
||||
$targetDate = $_GET['date'] ?? date("Y-m-d");
|
||||
// 简单日期格式校验 Y-m-d
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $targetDate)) {
|
||||
header("Content-Type:application/json;charset=utf-8");
|
||||
echo json_encode(["code"=>400,"msg"=>"日期格式错误,请使用 Y-m-d,例如 2026-07-01"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
|
||||
$singleDate = $_GET['date'] ?? '';
|
||||
$startDate = $_GET['start'] ?? '';
|
||||
$endDate = $_GET['end'] ?? '';
|
||||
$where = "";
|
||||
$fileName = "";
|
||||
|
||||
if (!empty($startDate) && !empty($endDate)) {
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
|
||||
header("Content-Type:application/json;charset=utf-8");
|
||||
echo json_encode(["code"=>400,"msg"=>"日期格式错误"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
if ($startDate > $endDate) {
|
||||
header("Content-Type:application/json;charset=utf-8");
|
||||
echo json_encode(["code"=>400,"msg"=>"开始日期不能大于结束日期"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$where = "receive_time >= '$startDate 00:00:00' AND receive_time <= '$endDate 23:59:59'";
|
||||
$fileName = "告警报表_{$startDate}_至_{$endDate}.csv";
|
||||
} elseif (!empty($singleDate)) {
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $singleDate)) {
|
||||
header("Content-Type:application/json;charset=utf-8");
|
||||
echo json_encode(["code"=>400,"msg"=>"日期格式错误"], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$where = "DATE(receive_time) = '$singleDate'";
|
||||
$fileName = "告警报表_{$singleDate}.csv";
|
||||
} else {
|
||||
$today = date("Y-m-d");
|
||||
$where = "DATE(receive_time) = '$today'";
|
||||
$fileName = "告警报表_今日_{$today}.csv";
|
||||
}
|
||||
|
||||
// 调试:打印where条件,访问接口先看这个
|
||||
// echo "WHERE条件:".$where;exit;
|
||||
|
||||
$sql = "SELECT alert_type,alert_name,instance,severity,starts_at,receive_time
|
||||
FROM alert_log WHERE DATE(receive_time) = '$targetDate' ORDER BY receive_time DESC";
|
||||
FROM alert_log WHERE {$where} ORDER BY receive_time DESC";
|
||||
$res = mysqli_query($conn, $sql);
|
||||
|
||||
ob_clean();
|
||||
header("Content-Type: text/csv; charset=utf-8");
|
||||
header("Content-Disposition: attachment; filename=告警报表_" . $targetDate . ".csv");
|
||||
header("Content-Disposition: attachment; filename={$fileName}");
|
||||
echo "\xEF\xBB\xBF";
|
||||
$header = ["告警状态", "告警名称", "实例", "级别", "故障开始时间", "接收时间"];
|
||||
echo implode(",", $header) . "\r\n";
|
||||
@@ -69,7 +99,6 @@ if ($act === "export_csv") {
|
||||
mysqli_close($conn);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 所有JSON接口统一返回头
|
||||
header("Content-Type:application/json;charset=utf-8");
|
||||
$conn = getDbConn();
|
||||
|
||||
@@ -322,13 +322,25 @@ button.normal:hover{
|
||||
|
||||
<!-- 9 导出CSV报表(带日历选择) -->
|
||||
<div class="card">
|
||||
<h3><i class="fa fa-download"></i>9. 导出告警CSV报表(支持历史日期)</h3>
|
||||
<div class="row">
|
||||
<label>选择日期:</label>
|
||||
<h3><i class="fa fa-download"></i>9. 导出告警CSV报表</h3>
|
||||
|
||||
<!-- 第一组:单日导出(原有功能保留) -->
|
||||
<div class="row" style="margin-bottom:12px;gap:10px;align-items:center;flex-wrap:wrap;">
|
||||
<label>单日导出:</label>
|
||||
<input type="date" id="csv_date" placeholder="留空导出今日">
|
||||
<button class="btn-success" onclick="exportCsv()"><i class="fa fa-download"></i>下载对应日期报表</button>
|
||||
<button class="btn-success" onclick="exportCsvSingle()"><i class="fa fa-download"></i>下载单日报表</button>
|
||||
</div>
|
||||
<div class="tip">不选择日期默认导出今日告警;选择历史日期可下载过往报表</div>
|
||||
<div class="tip" style="margin-bottom:16px;">不选择日期默认导出今日告警;选择历史日期下载单日报表</div>
|
||||
|
||||
<!-- 第二组:区间导出(新增) -->
|
||||
<div class="row" style="gap:10px;align-items:center;flex-wrap:wrap;">
|
||||
<label>区间导出:</label>
|
||||
<input type="date" id="csv_start">
|
||||
<span>~</span>
|
||||
<input type="date" id="csv_end">
|
||||
<button class="btn-primary" onclick="exportCsvRange()"><i class="fa fa-download"></i>下载区间报表</button>
|
||||
</div>
|
||||
<div class="tip">填写开始、结束日期,导出该时间段全部告警数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 返回结果输出 -->
|
||||
@@ -397,8 +409,8 @@ function simpleReq(act){
|
||||
})
|
||||
}
|
||||
|
||||
// 导出CSV 支持日历选择日期
|
||||
function exportCsv(){
|
||||
// 单日导出
|
||||
function exportCsvSingle(){
|
||||
const dateVal = document.getElementById("csv_date").value.trim();
|
||||
let url = `${apiUrl}?act=export_csv`;
|
||||
if(dateVal){
|
||||
@@ -406,6 +418,23 @@ function exportCsv(){
|
||||
}
|
||||
window.open(url,"_blank");
|
||||
}
|
||||
|
||||
// 区间导出
|
||||
function exportCsvRange(){
|
||||
const startDate = document.getElementById("csv_start").value.trim();
|
||||
const endDate = document.getElementById("csv_end").value.trim();
|
||||
if(!startDate || !endDate){
|
||||
alert("请同时选择开始日期和结束日期");
|
||||
return;
|
||||
}
|
||||
if(startDate > endDate){
|
||||
alert("开始日期不能晚于结束日期");
|
||||
return;
|
||||
}
|
||||
let url = `${apiUrl}?act=export_csv`;
|
||||
url += `&start=${encodeURIComponent(startDate)}&end=${encodeURIComponent(endDate)}`;
|
||||
window.open(url,"_blank");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,7 +9,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
}
|
||||
session_start();
|
||||
$expire = 1800;
|
||||
if (empty($_SESSION['user_id']) || (time() - $_SESSION['login_time'] > $expire)) {
|
||||
if (empty($_SESSION['user_id']) || (time() - $_SESSION['login_time']) > $expire) {
|
||||
echo json_encode(['code' => 401, 'msg' => '登录失效']);
|
||||
exit;
|
||||
}
|
||||
@@ -42,7 +42,8 @@ mysqli_set_charset($connConf, "utf8mb4");
|
||||
// 获取当前用户角色权限
|
||||
$isAdmin = 0;
|
||||
$permList = "";
|
||||
$roleSql = "SELECT r.is_admin, r.perm_list FROM sys_user u LEFT JOIN sys_role r ON u.role_id=r.id WHERE u.uid = ?";
|
||||
$roleId = 0;
|
||||
$roleSql = "SELECT r.is_admin, r.perm_list, u.role_id FROM sys_user u LEFT JOIN sys_role r ON u.role_id=r.id WHERE u.uid = ?";
|
||||
$stmtRole = mysqli_prepare($connWork, $roleSql);
|
||||
mysqli_stmt_bind_param($stmtRole, 's', $loginUid);
|
||||
mysqli_stmt_execute($stmtRole);
|
||||
@@ -51,12 +52,98 @@ $roleRow = $roleRes ? mysqli_fetch_assoc($roleRes) : [];
|
||||
if ($roleRow) {
|
||||
$isAdmin = intval($roleRow['is_admin']);
|
||||
$permList = $roleRow['perm_list'];
|
||||
$roleId = intval($roleRow['role_id'] ?? 0);
|
||||
}
|
||||
|
||||
// 读取当前角色页面权限列表(用于分发工单权限判断)
|
||||
$allowPages = [];
|
||||
if ($roleId > 0) {
|
||||
$permSql = "SELECT page_key FROM sys_role_permission WHERE role_id = ?";
|
||||
$stmtPerm = mysqli_prepare($connWork, $permSql);
|
||||
mysqli_stmt_bind_param($stmtPerm, 'i', $roleId);
|
||||
mysqli_stmt_execute($stmtPerm);
|
||||
$pRes = mysqli_stmt_get_result($stmtPerm);
|
||||
while ($p = mysqli_fetch_assoc($pRes)) {
|
||||
$allowPages[] = $p['page_key'];
|
||||
}
|
||||
}
|
||||
|
||||
$action = $_REQUEST['action'] ?? '';
|
||||
$post = json_decode(file_get_contents("php://input"), true) ?: [];
|
||||
|
||||
// ===================== 新增1:根据告警ID查询关联工单 =====================
|
||||
// ===================== 工单列表查询 =====================
|
||||
if ($action === "get_list") {
|
||||
$page = intval($_GET['page'] ?? 1);
|
||||
$size = intval($_GET['size'] ?? 20);
|
||||
$offset = ($page - 1) * $size;
|
||||
$status = trim($_GET['status'] ?? '');
|
||||
$keyword = trim($_GET['keyword'] ?? '');
|
||||
|
||||
$where = [];
|
||||
$param = [];
|
||||
$paramType = '';
|
||||
|
||||
// 全部用户查看全部工单,不再按uid过滤
|
||||
$whereSql = "1=1";
|
||||
|
||||
if ($status !== '') {
|
||||
$where[] = "status = ?";
|
||||
$param[] = $status;
|
||||
$paramType .= 'i';
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$where[] = "(title LIKE ? OR content LIKE ?)";
|
||||
$param[] = "%$keyword%";
|
||||
$param[] = "%$keyword%";
|
||||
$paramType .= 'ss';
|
||||
}
|
||||
|
||||
if (!empty($where)) {
|
||||
$whereSql = "WHERE " . implode(" AND ", $where);
|
||||
}
|
||||
|
||||
// 分页列表,LEFT JOIN 认领人姓名
|
||||
$listSql = "SELECT wo.*, uu.real_name claim_name
|
||||
FROM work_order wo
|
||||
LEFT JOIN sys_user uu ON wo.claim_uid = uu.uid
|
||||
$whereSql ORDER BY create_time DESC LIMIT ?,?";
|
||||
$param[] = $offset;
|
||||
$param[] = $size;
|
||||
$paramType .= 'ii';
|
||||
|
||||
$stmtList = mysqli_prepare($connWork, $listSql);
|
||||
mysqli_stmt_bind_param($stmtList, $paramType, ...$param);
|
||||
mysqli_stmt_execute($stmtList);
|
||||
$res = mysqli_stmt_get_result($stmtList);
|
||||
$list = [];
|
||||
while ($row = mysqli_fetch_assoc($res)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
// 总数统计
|
||||
$countSql = "SELECT COUNT(id) total FROM work_order $whereSql";
|
||||
$stmtCount = mysqli_prepare($connWork, $countSql);
|
||||
array_pop($param);
|
||||
array_pop($param);
|
||||
$paramTypeCount = rtrim($paramType, 'ii');
|
||||
if ($paramTypeCount) {
|
||||
mysqli_stmt_bind_param($stmtCount, $paramTypeCount, ...$param);
|
||||
}
|
||||
mysqli_stmt_execute($stmtCount);
|
||||
$countRow = mysqli_fetch_assoc(mysqli_stmt_get_result($stmtCount));
|
||||
$total = intval($countRow['total']);
|
||||
|
||||
echo json_encode([
|
||||
'code' => 0,
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'size' => $size
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===================== 根据告警ID查询关联工单 =====================
|
||||
if ($action === "get_workorder_by_alertid") {
|
||||
$alertId = trim($_GET['alert_id'] ?? '');
|
||||
if (empty($alertId)) {
|
||||
@@ -77,9 +164,8 @@ if ($action === "get_workorder_by_alertid") {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===================== 新增2:自动同步告警状态,批量更新工单为已完成 =====================
|
||||
// ===================== 自动同步告警状态,批量更新工单为已完成 =====================
|
||||
if ($action === "auto_sync_workorder_status") {
|
||||
// 1. 查询所有已恢复的告警(alert_type=2),去重获取告警ID
|
||||
$resolveSql = "SELECT DISTINCT CONCAT(alert_name,'_',instance) as alert_id FROM alert_log WHERE alert_type=2";
|
||||
$resolveRes = mysqli_query($connConf, $resolveSql);
|
||||
$resolveAlertIds = [];
|
||||
@@ -91,11 +177,9 @@ if ($action === "auto_sync_workorder_status") {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. 拼接IN条件,查询未完成、关联该告警的工单
|
||||
$inStr = implode("','", array_map(function($v) use ($connWork) {
|
||||
return mysqli_real_escape_string($connWork, $v);
|
||||
}, $resolveAlertIds));
|
||||
// status=1待处理 / status=2处理中,统一改为3已完成
|
||||
$updateSql = "UPDATE work_order SET status=3,update_time=NOW() WHERE relate_alert_id IN ('$inStr') AND status IN (1,2)";
|
||||
mysqli_query($connWork, $updateSql);
|
||||
$updateCnt = mysqli_affected_rows($connWork);
|
||||
@@ -122,7 +206,7 @@ if ($action === "get_firing_alert") {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2、新建工单(关联告警、多渠道通知 + 自动判断告警是否已恢复)
|
||||
// 2、新建工单
|
||||
if ($action === "create") {
|
||||
$title = trim($post['title'] ?? '');
|
||||
$content = trim($post['content'] ?? '');
|
||||
@@ -130,7 +214,6 @@ if ($action === "create") {
|
||||
$relateAlertId = trim($post['relate_alert_id'] ?? '');
|
||||
$notifyUser = trim($post['notify_user'] ?? '');
|
||||
$notifyChannel = trim($post['notify_channel'] ?? '');
|
||||
// 默认待处理1;若关联告警已恢复,直接置完成3
|
||||
$status = 1;
|
||||
|
||||
if (!$title || !$content) {
|
||||
@@ -138,7 +221,6 @@ if ($action === "create") {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 关联了告警,判断该告警是否存在恢复记录
|
||||
if (!empty($relateAlertId)) {
|
||||
$alertEsc = mysqli_real_escape_string($connConf, $relateAlertId);
|
||||
$checkResolveSql = "SELECT 1 FROM alert_log WHERE CONCAT(alert_name,'_',instance) = ? AND alert_type=2 LIMIT 1";
|
||||
@@ -151,8 +233,8 @@ if ($action === "create") {
|
||||
}
|
||||
}
|
||||
|
||||
// 新增工单默认未读 is_read=0
|
||||
$sql = "INSERT INTO work_order(title,content,create_uid,assign_uid,relate_alert_id,notify_user,notify_channel,status,is_read,create_time,update_time) VALUES (?,?,?,?,?,?,?,?,0,NOW(),NOW())";
|
||||
// 新建工单默认无认领人 claim_uid=''
|
||||
$sql = "INSERT INTO work_order(title,content,create_uid,assign_uid,relate_alert_id,notify_user,notify_channel,status,is_read,claim_uid,create_time,update_time) VALUES (?,?,?,?,?,?,?,?,?, '',NOW(),NOW())";
|
||||
$stmt = mysqli_prepare($connWork, $sql);
|
||||
mysqli_stmt_bind_param($stmt, 'ssssssss', $title, $content, $loginUid, $assign, $relateAlertId, $notifyUser, $notifyChannel, $status);
|
||||
$ok = mysqli_stmt_execute($stmt);
|
||||
@@ -166,7 +248,37 @@ if ($action === "create") {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3、编辑工单
|
||||
// ========== 新增:工单认领接口 claim_work ==========
|
||||
if ($action === "claim_work") {
|
||||
$id = intval($post['id'] ?? 0);
|
||||
$uid = trim($post['uid'] ?? '');
|
||||
if ($id <= 0 || empty($uid)) {
|
||||
echo json_encode(["code" => 400, "msg" => "参数错误"]);
|
||||
exit;
|
||||
}
|
||||
// 校验工单未被认领
|
||||
$chkSql = "SELECT id FROM work_order WHERE id=? AND (claim_uid IS NULL OR claim_uid = '')";
|
||||
$stmtChk = mysqli_prepare($connWork, $chkSql);
|
||||
mysqli_stmt_bind_param($stmtChk, 'i', $id);
|
||||
mysqli_stmt_execute($stmtChk);
|
||||
$chkRes = mysqli_stmt_get_result($stmtChk);
|
||||
if(mysqli_num_rows($chkRes) === 0){
|
||||
echo json_encode(["code" => 400, "msg" => "该工单已被他人认领,无法重复认领"]);
|
||||
exit;
|
||||
}
|
||||
$updateSql = "UPDATE work_order SET claim_uid=?, claim_time=NOW() WHERE id=?";
|
||||
$stmt = mysqli_prepare($connWork, $updateSql);
|
||||
mysqli_stmt_bind_param($stmt, 'si', $uid, $id);
|
||||
$ok = mysqli_stmt_execute($stmt);
|
||||
if($ok){
|
||||
echo json_encode(["code" => 0, "msg" => "工单认领成功,现在你可以编辑处理"]);
|
||||
}else{
|
||||
echo json_encode(["code" => 500, "msg" => "认领失败:".mysqli_error($connWork)]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3、编辑工单(权限:管理员 或 当前用户是认领人)
|
||||
if ($action === "edit") {
|
||||
$id = intval($post['id'] ?? 0);
|
||||
$title = trim($post['title'] ?? '');
|
||||
@@ -179,15 +291,15 @@ if ($action === "edit") {
|
||||
echo json_encode(['code' => 400, 'msg' => '参数不能为空']);
|
||||
exit;
|
||||
}
|
||||
// 非管理员权限校验
|
||||
// 权限重写
|
||||
if ($isAdmin !== 1) {
|
||||
$chkSql = "SELECT id FROM work_order WHERE id=? AND (create_uid=? OR assign_uid=?)";
|
||||
$chkSql = "SELECT id FROM work_order WHERE id=? AND claim_uid=?";
|
||||
$stmtChk = mysqli_prepare($connWork, $chkSql);
|
||||
mysqli_stmt_bind_param($stmtChk, 'iss', $id, $loginUid, $loginUid);
|
||||
mysqli_stmt_bind_param($stmtChk, 'is', $id, $loginUid);
|
||||
mysqli_stmt_execute($stmtChk);
|
||||
$chkRes = mysqli_stmt_get_result($stmtChk);
|
||||
if (mysqli_num_rows($chkRes) === 0) {
|
||||
echo json_encode(['code' => 403, 'msg' => '无权限编辑该工单']);
|
||||
echo json_encode(['code' => 403, 'msg' => '仅工单认领人可编辑']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -200,7 +312,7 @@ if ($action === "edit") {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4、修改工单状态
|
||||
// 4、修改工单状态(权限:管理员 或 当前用户是认领人)
|
||||
if ($action === "update_status") {
|
||||
$id = intval($post['id'] ?? 0);
|
||||
$status = intval($post['status'] ?? 1);
|
||||
@@ -209,17 +321,16 @@ if ($action === "update_status") {
|
||||
exit;
|
||||
}
|
||||
if ($isAdmin !== 1) {
|
||||
$chkSql = "SELECT id FROM work_order WHERE id=? AND (create_uid=? OR assign_uid=?)";
|
||||
$chkSql = "SELECT id FROM work_order WHERE id=? AND claim_uid=?";
|
||||
$stmtChk = mysqli_prepare($connWork, $chkSql);
|
||||
mysqli_stmt_bind_param($stmtChk, 'iss', $id, $loginUid, $loginUid);
|
||||
mysqli_stmt_bind_param($stmtChk, 'is', $id, $loginUid);
|
||||
mysqli_stmt_execute($stmtChk);
|
||||
$chkRes = mysqli_stmt_get_result($stmtChk);
|
||||
if (mysqli_num_rows($chkRes) === 0) {
|
||||
echo json_encode(['code' => 403, 'msg' => '无权限操作该工单']);
|
||||
echo json_encode(['code' => 403, 'msg' => '仅工单认领人可修改状态']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
// 修改状态自动标记已读
|
||||
$sql = "UPDATE work_order SET status=?,is_read=1,update_time=NOW() WHERE id=?";
|
||||
$stmt = mysqli_prepare($connWork, $sql);
|
||||
mysqli_stmt_bind_param($stmt, 'ii', $status, $id);
|
||||
@@ -228,15 +339,14 @@ if ($action === "update_status") {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5、管理员分发工单(前端统一action=assign,废弃assign_order)
|
||||
// 5、管理员/拥有system_manage权限角色 分发工单
|
||||
if ($action === "assign") {
|
||||
if ($isAdmin !== 1) {
|
||||
echo json_encode(['code' => 403, 'msg' => '仅管理员可分发工单']);
|
||||
if ($isAdmin !== 1 && !in_array("system_manage", $allowPages)) {
|
||||
echo json_encode(['code' => 403, 'msg' => '无工单分发权限,请联系管理员']);
|
||||
exit;
|
||||
}
|
||||
$id = intval($post['id'] ?? 0);
|
||||
$assignUid = trim($post['assign_uid'] ?? '');
|
||||
// 修复BUG:仅按工单唯一ID更新,不再使用标题模糊匹配,选中哪一行只修改该行工单
|
||||
$sql = "UPDATE work_order SET assign_uid=?,is_read=0,update_time=NOW() WHERE id=?";
|
||||
$stmt = mysqli_prepare($connWork, $sql);
|
||||
mysqli_stmt_bind_param($stmt, 'si', $assignUid, $id);
|
||||
@@ -267,7 +377,7 @@ if ($action === 'save_lang') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 新增:同步Prometheus实时告警,自动清理恢复告警(完全独立,无需index.php)
|
||||
// 同步Prometheus实时告警,自动清理恢复告警
|
||||
if ($action === "sync_prom_alerts") {
|
||||
$promUrl = "http://10.150.117.190:9090/api/v1/alerts";
|
||||
$resp = @file_get_contents($promUrl);
|
||||
@@ -281,10 +391,8 @@ if ($action === "sync_prom_alerts") {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1. 先将表内所有告警标记为已恢复
|
||||
mysqli_query($connConf,"UPDATE alert_firing SET status='resolved'");
|
||||
|
||||
// 2. 遍历当前Prometheus活跃告警,写入/更新
|
||||
$alerts = $promData['data']['alerts'] ?? [];
|
||||
foreach($alerts as $item){
|
||||
$alertName = $item['labels']['alertname'] ?? "unknown";
|
||||
@@ -297,7 +405,6 @@ if ($action === "sync_prom_alerts") {
|
||||
}
|
||||
$syncNum = count($alerts);
|
||||
|
||||
// 同步完成后自动执行工单状态更新
|
||||
$syncWorkUrl = $_SERVER['REQUEST_SCHEME'] . "://" . $_SERVER['HTTP_HOST'] . $_SERVER['SCRIPT_NAME'] . "?action=auto_sync_workorder_status";
|
||||
@file_get_contents($syncWorkUrl);
|
||||
|
||||
@@ -311,19 +418,16 @@ if ($action === "sync_prom_alerts") {
|
||||
|
||||
// 定时同步:告警恢复自动完工单
|
||||
if($action === "sync_alert_auto_close"){
|
||||
// 1、拉取仪表盘实时未恢复告警
|
||||
$alertApi = "./index.php?act=active_firing";
|
||||
$raw = @file_get_contents($alertApi);
|
||||
$activeList = json_decode($raw, true) ?: [];
|
||||
$activeKeys = [];
|
||||
foreach($activeList as $item){
|
||||
$activeKeys[] = $item['alert_name'] . "|" . $item['instance'];
|
||||
$activeKeys[] = $item['alert_name'] . "_" . $item['instance'];
|
||||
}
|
||||
// 2、查询所有未完成、绑定告警的工单
|
||||
$sql = "SELECT id, relate_alert_id FROM work_order WHERE status IN(1,2) AND relate_alert_id <> ''";
|
||||
$res = mysqli_query($connWork, $sql);
|
||||
while($row = mysqli_fetch_assoc($res)){
|
||||
// 工单关联告警不在当前活跃列表=已恢复,自动设为完成
|
||||
if(!in_array($row['relate_alert_id'], $activeKeys)){
|
||||
$updateSql = "UPDATE work_order SET status=3, update_time=NOW() WHERE id = ".(int)$row['id'];
|
||||
mysqli_query($connWork, $updateSql);
|
||||
@@ -340,12 +444,10 @@ function sendWorkOrderNotify($connConf, $connWork, $orderId, $notifyUserStr, $ch
|
||||
$userArr = explode(",", $notifyUserStr);
|
||||
$channelArr = explode(",", $channelStr);
|
||||
|
||||
// 读取完整通知配置(邮箱+钉钉+企微)
|
||||
$cfgRes = mysqli_query($connConf, "SELECT * FROM sys_smtp_config WHERE id=1");
|
||||
$notifyCfg = $cfgRes ? mysqli_fetch_assoc($cfgRes) : [];
|
||||
if(empty($notifyCfg)) return;
|
||||
|
||||
// 获取工单完整信息
|
||||
$orderRes = mysqli_query($connWork, "SELECT * FROM work_order WHERE id=$orderId");
|
||||
$order = $orderRes ? mysqli_fetch_assoc($orderRes) : [];
|
||||
$title = $order['title'];
|
||||
@@ -389,7 +491,6 @@ function sendMailNotify($smtp, $connWork, $uidList, $title, $content, $relateId,
|
||||
<p>关联告警ID:$relateId</p>
|
||||
<p>标题:$title</p>
|
||||
<pre style='background:#f5f5f5;padding:10px'>$content</pre>";
|
||||
// 此处接入PHPMailer发送逻辑
|
||||
}
|
||||
|
||||
// 钉钉机器人推送
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET,POST,OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type");
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
echo json_encode(['code' => 0, 'msg' => 'ok']);
|
||||
exit;
|
||||
}
|
||||
session_start();
|
||||
$expire = 1800;
|
||||
if (empty($_SESSION['user_id']) || (time() - $_SESSION['login_time']) > $expire) {
|
||||
echo json_encode(['code' => 401, 'msg' => '登录失效']);
|
||||
exit;
|
||||
}
|
||||
$loginUid = $_SESSION['user_id'];
|
||||
|
||||
// 工单业务库 monitor
|
||||
$dbWorkHost = '10.150.117.190';
|
||||
$dbWorkUser = 'root';
|
||||
$dbWorkPwd = 'hp93000';
|
||||
$dbWorkName = 'monitor';
|
||||
$connWork = mysqli_connect($dbWorkHost, $dbWorkUser, $dbWorkPwd, $dbWorkName);
|
||||
if (!$connWork) {
|
||||
echo json_encode(['code' => 500, 'msg' => '工单数据库连接失败:' . mysqli_connect_error()]);
|
||||
exit;
|
||||
}
|
||||
mysqli_set_charset($connWork, 'utf8mb4');
|
||||
|
||||
// 配置库 alert_mail_stat(告警、通知配置统一此处)
|
||||
$dbConfHost = "10.150.117.190";
|
||||
$dbConfUser = "root";
|
||||
$dbConfPwd = "hp93000";
|
||||
$dbConfName = "alert_mail_stat";
|
||||
$connConf = mysqli_connect($dbConfHost, $dbConfUser, $dbConfPwd, $dbConfName);
|
||||
if(!$connConf){
|
||||
echo json_encode(['code' => 500, 'msg' => '配置库连接失败']);
|
||||
exit;
|
||||
}
|
||||
mysqli_set_charset($connConf, "utf8mb4");
|
||||
|
||||
// 获取当前用户角色权限
|
||||
$isAdmin = 0;
|
||||
$permList = "";
|
||||
$roleId = 0;
|
||||
$roleSql = "SELECT r.is_admin, r.perm_list, u.role_id FROM sys_user u LEFT JOIN sys_role r ON u.role_id=r.id WHERE u.uid = ?";
|
||||
$stmtRole = mysqli_prepare($connWork, $roleSql);
|
||||
mysqli_stmt_bind_param($stmtRole, 's', $loginUid);
|
||||
mysqli_stmt_execute($stmtRole);
|
||||
$roleRes = mysqli_stmt_get_result($stmtRole);
|
||||
$roleRow = $roleRes ? mysqli_fetch_assoc($roleRes) : [];
|
||||
if ($roleRow) {
|
||||
$isAdmin = intval($roleRow['is_admin']);
|
||||
$permList = $roleRow['perm_list'];
|
||||
$roleId = intval($roleRow['role_id'] ?? 0);
|
||||
}
|
||||
|
||||
// 读取当前角色页面权限列表(用于分发工单权限判断)
|
||||
$allowPages = [];
|
||||
if ($roleId > 0) {
|
||||
$permSql = "SELECT page_key FROM sys_role_permission WHERE role_id = ?";
|
||||
$stmtPerm = mysqli_prepare($connWork, $permSql);
|
||||
mysqli_stmt_bind_param($stmtPerm, 'i', $roleId);
|
||||
mysqli_stmt_execute($stmtPerm);
|
||||
$pRes = mysqli_stmt_get_result($stmtPerm);
|
||||
while ($p = mysqli_fetch_assoc($pRes)) {
|
||||
$allowPages[] = $p['page_key'];
|
||||
}
|
||||
}
|
||||
|
||||
$action = $_REQUEST['action'] ?? '';
|
||||
$post = json_decode(file_get_contents("php://input"), true) ?: [];
|
||||
|
||||
// ===================== 工单列表查询(新增,解决数据可见权限隔离) =====================
|
||||
if ($action === "get_list") {
|
||||
$page = intval($_GET['page'] ?? 1);
|
||||
$size = intval($_GET['size'] ?? 20);
|
||||
$offset = ($page - 1) * $size;
|
||||
$status = trim($_GET['status'] ?? '');
|
||||
$keyword = trim($_GET['keyword'] ?? '');
|
||||
|
||||
$where = [];
|
||||
$param = [];
|
||||
$paramType = '';
|
||||
|
||||
// 普通用户:仅能查看自己创建 或 分配给自己的工单;管理员无限制查看全部
|
||||
if ($isAdmin !== 1) {
|
||||
$where[] = "(create_uid = ? OR assign_uid = ?)";
|
||||
$param[] = $loginUid;
|
||||
$param[] = $loginUid;
|
||||
$paramType .= 'ss';
|
||||
}
|
||||
|
||||
if ($status !== '') {
|
||||
$where[] = "status = ?";
|
||||
$param[] = $status;
|
||||
$paramType .= 'i';
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$where[] = "(title LIKE ? OR content LIKE ?)";
|
||||
$param[] = "%$keyword%";
|
||||
$param[] = "%$keyword%";
|
||||
$paramType .= 'ss';
|
||||
}
|
||||
|
||||
$whereSql = $where ? "WHERE " . implode(" AND ", $where) : "";
|
||||
|
||||
// 分页列表
|
||||
$listSql = "SELECT * FROM work_order $whereSql ORDER BY create_time DESC LIMIT ?,?";
|
||||
$param[] = $offset;
|
||||
$param[] = $size;
|
||||
$paramType .= 'ii';
|
||||
|
||||
$stmtList = mysqli_prepare($connWork, $listSql);
|
||||
mysqli_stmt_bind_param($stmtList, $paramType, ...$param);
|
||||
mysqli_stmt_execute($stmtList);
|
||||
$res = mysqli_stmt_get_result($stmtList);
|
||||
$list = [];
|
||||
while ($row = mysqli_fetch_assoc($res)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
// 总数统计
|
||||
$countSql = "SELECT COUNT(id) total FROM work_order $whereSql";
|
||||
$stmtCount = mysqli_prepare($connWork, $countSql);
|
||||
array_pop($param);
|
||||
array_pop($param);
|
||||
$paramTypeCount = rtrim($paramType, 'ii');
|
||||
if ($paramTypeCount) {
|
||||
mysqli_stmt_bind_param($stmtCount, $paramTypeCount, ...$param);
|
||||
}
|
||||
mysqli_stmt_execute($stmtCount);
|
||||
$countRow = mysqli_fetch_assoc(mysqli_stmt_get_result($stmtCount));
|
||||
$total = intval($countRow['total']);
|
||||
|
||||
echo json_encode([
|
||||
'code' => 0,
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'size' => $size
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===================== 根据告警ID查询关联工单 =====================
|
||||
if ($action === "get_workorder_by_alertid") {
|
||||
$alertId = trim($_GET['alert_id'] ?? '');
|
||||
if (empty($alertId)) {
|
||||
echo json_encode(['code' => 400, 'msg' => '缺少告警ID参数']);
|
||||
exit;
|
||||
}
|
||||
$alertEsc = mysqli_real_escape_string($connWork, $alertId);
|
||||
$sql = "SELECT id,title,status,assign_uid,create_time FROM work_order WHERE relate_alert_id = ? ORDER BY create_time DESC";
|
||||
$stmt = mysqli_prepare($connWork, $sql);
|
||||
mysqli_stmt_bind_param($stmt, 's', $alertEsc);
|
||||
mysqli_stmt_execute($stmt);
|
||||
$res = mysqli_stmt_get_result($stmt);
|
||||
$list = [];
|
||||
while ($row = mysqli_fetch_assoc($res)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
echo json_encode(['code' => 0, 'list' => $list]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===================== 自动同步告警状态,批量更新工单为已完成 =====================
|
||||
if ($action === "auto_sync_workorder_status") {
|
||||
// 1. 查询所有已恢复的告警(alert_type=2),去重获取告警ID
|
||||
$resolveSql = "SELECT DISTINCT CONCAT(alert_name,'_',instance) as alert_id FROM alert_log WHERE alert_type=2";
|
||||
$resolveRes = mysqli_query($connConf, $resolveSql);
|
||||
$resolveAlertIds = [];
|
||||
while ($row = mysqli_fetch_assoc($resolveRes)) {
|
||||
$resolveAlertIds[] = $row['alert_id'];
|
||||
}
|
||||
if (empty($resolveAlertIds)) {
|
||||
echo json_encode(['code' => 0, 'msg' => '暂无已恢复告警,无需更新工单', 'update_count' => 0]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. 拼接IN条件,查询未完成、关联该告警的工单
|
||||
$inStr = implode("','", array_map(function($v) use ($connWork) {
|
||||
return mysqli_real_escape_string($connWork, $v);
|
||||
}, $resolveAlertIds));
|
||||
// status=1待处理 / status=2处理中,统一改为3已完成
|
||||
$updateSql = "UPDATE work_order SET status=3,update_time=NOW() WHERE relate_alert_id IN ('$inStr') AND status IN (1,2)";
|
||||
mysqli_query($connWork, $updateSql);
|
||||
$updateCnt = mysqli_affected_rows($connWork);
|
||||
|
||||
echo json_encode([
|
||||
'code' => 0,
|
||||
'msg' => '工单状态自动同步完成,已恢复告警绑定工单全部置为已完成',
|
||||
'update_count' => $updateCnt
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1、获取实时活跃告警(alert_mail_stat.alert_firing 数据源)
|
||||
if ($action === "get_firing_alert") {
|
||||
$alertSql = "SELECT alert_id,alert_name,instance FROM alert_firing WHERE status='firing' ORDER BY receive_time DESC LIMIT 100";
|
||||
$res = mysqli_query($connConf, $alertSql);
|
||||
$list = [];
|
||||
if($res instanceof mysqli_result){
|
||||
while ($row = mysqli_fetch_assoc($res)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
}
|
||||
echo json_encode(['code' => 0, 'list' => $list]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2、新建工单(关联告警、多渠道通知 + 自动判断告警是否已恢复)
|
||||
if ($action === "create") {
|
||||
$title = trim($post['title'] ?? '');
|
||||
$content = trim($post['content'] ?? '');
|
||||
$assign = trim($post['assign_uid'] ?? '');
|
||||
$relateAlertId = trim($post['relate_alert_id'] ?? '');
|
||||
$notifyUser = trim($post['notify_user'] ?? '');
|
||||
$notifyChannel = trim($post['notify_channel'] ?? '');
|
||||
// 默认待处理1;若关联告警已恢复,直接置完成3
|
||||
$status = 1;
|
||||
|
||||
if (!$title || !$content) {
|
||||
echo json_encode(['code' => 400, 'msg' => '标题和内容不能为空']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 关联了告警,判断该告警是否存在恢复记录
|
||||
if (!empty($relateAlertId)) {
|
||||
$alertEsc = mysqli_real_escape_string($connConf, $relateAlertId);
|
||||
$checkResolveSql = "SELECT 1 FROM alert_log WHERE CONCAT(alert_name,'_',instance) = ? AND alert_type=2 LIMIT 1";
|
||||
$stmtCheck = mysqli_prepare($connConf, $checkResolveSql);
|
||||
mysqli_stmt_bind_param($stmtCheck, 's', $alertEsc);
|
||||
mysqli_stmt_execute($stmtCheck);
|
||||
$checkRes = mysqli_stmt_get_result($stmtCheck);
|
||||
if (mysqli_num_rows($checkRes) > 0) {
|
||||
$status = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// 新增工单默认未读 is_read=0
|
||||
$sql = "INSERT INTO work_order(title,content,create_uid,assign_uid,relate_alert_id,notify_user,notify_channel,status,is_read,create_time,update_time) VALUES (?,?,?,?,?,?,?,?,0,NOW(),NOW())";
|
||||
$stmt = mysqli_prepare($connWork, $sql);
|
||||
mysqli_stmt_bind_param($stmt, 'ssssssss', $title, $content, $loginUid, $assign, $relateAlertId, $notifyUser, $notifyChannel, $status);
|
||||
$ok = mysqli_stmt_execute($stmt);
|
||||
if (!$ok) {
|
||||
echo json_encode(['code' => 500, 'msg' => '创建失败:' . mysqli_error($connWork)]);
|
||||
exit;
|
||||
}
|
||||
$orderId = mysqli_insert_id($connWork);
|
||||
sendWorkOrderNotify($connConf, $connWork, $orderId, $notifyUser, $notifyChannel);
|
||||
echo json_encode(['code' => 0, 'msg' => '工单提交成功,通知已下发', 'init_status' => $status]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3、编辑工单
|
||||
if ($action === "edit") {
|
||||
$id = intval($post['id'] ?? 0);
|
||||
$title = trim($post['title'] ?? '');
|
||||
$content = trim($post['content'] ?? '');
|
||||
$assign = trim($post['assign_uid'] ?? '');
|
||||
$relateAlertId = trim($post['relate_alert_id'] ?? '');
|
||||
$notifyUser = trim($post['notify_user'] ?? '');
|
||||
$notifyChannel = trim($post['notify_channel'] ?? '');
|
||||
if ($id <= 0 || empty($title) || empty($content)) {
|
||||
echo json_encode(['code' => 400, 'msg' => '参数不能为空']);
|
||||
exit;
|
||||
}
|
||||
// 非管理员权限校验
|
||||
if ($isAdmin !== 1) {
|
||||
$chkSql = "SELECT id FROM work_order WHERE id=? AND (create_uid=? OR assign_uid=?)";
|
||||
$stmtChk = mysqli_prepare($connWork, $chkSql);
|
||||
mysqli_stmt_bind_param($stmtChk, 'iss', $id, $loginUid, $loginUid);
|
||||
mysqli_stmt_execute($stmtChk);
|
||||
$chkRes = mysqli_stmt_get_result($stmtChk);
|
||||
if (mysqli_num_rows($chkRes) === 0) {
|
||||
echo json_encode(['code' => 403, 'msg' => '无权限编辑该工单']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$sql = "UPDATE work_order SET title=?,content=?,assign_uid=?,relate_alert_id=?,notify_user=?,notify_channel=?,update_time=NOW() WHERE id=?";
|
||||
$stmt = mysqli_prepare($connWork, $sql);
|
||||
mysqli_stmt_bind_param($stmt, 'ssssssi', $title, $content, $assign, $relateAlertId, $notifyUser, $notifyChannel, $id);
|
||||
mysqli_stmt_execute($stmt);
|
||||
sendWorkOrderNotify($connConf, $connWork, $id, $notifyUser, $notifyChannel);
|
||||
echo json_encode(['code' => 0, 'msg' => '工单保存成功,通知已更新下发']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4、修改工单状态
|
||||
if ($action === "update_status") {
|
||||
$id = intval($post['id'] ?? 0);
|
||||
$status = intval($post['status'] ?? 1);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['code' => 400, 'msg' => '工单ID错误']);
|
||||
exit;
|
||||
}
|
||||
if ($isAdmin !== 1) {
|
||||
$chkSql = "SELECT id FROM work_order WHERE id=? AND (create_uid=? OR assign_uid=?)";
|
||||
$stmtChk = mysqli_prepare($connWork, $chkSql);
|
||||
mysqli_stmt_bind_param($stmtChk, 'iss', $id, $loginUid, $loginUid);
|
||||
mysqli_stmt_execute($stmtChk);
|
||||
$chkRes = mysqli_stmt_get_result($stmtChk);
|
||||
if (mysqli_num_rows($chkRes) === 0) {
|
||||
echo json_encode(['code' => 403, 'msg' => '无权限操作该工单']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
// 修改状态自动标记已读
|
||||
$sql = "UPDATE work_order SET status=?,is_read=1,update_time=NOW() WHERE id=?";
|
||||
$stmt = mysqli_prepare($connWork, $sql);
|
||||
mysqli_stmt_bind_param($stmt, 'ii', $status, $id);
|
||||
mysqli_stmt_execute($stmt);
|
||||
echo json_encode(['code' => 0, 'msg' => '状态更新成功']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 5、管理员/拥有system_manage权限角色 分发工单
|
||||
if ($action === "assign") {
|
||||
// 修复:超级管理员 或 拥有system_manage页面权限均可分发工单
|
||||
if ($isAdmin !== 1 && !in_array("system_manage", $allowPages)) {
|
||||
echo json_encode(['code' => 403, 'msg' => '无工单分发权限,请联系管理员']);
|
||||
exit;
|
||||
}
|
||||
$id = intval($post['id'] ?? 0);
|
||||
$assignUid = trim($post['assign_uid'] ?? '');
|
||||
// 修复BUG:仅按工单唯一ID更新,不再使用标题模糊匹配,选中哪一行只修改该行工单
|
||||
$sql = "UPDATE work_order SET assign_uid=?,is_read=0,update_time=NOW() WHERE id=?";
|
||||
$stmt = mysqli_prepare($connWork, $sql);
|
||||
mysqli_stmt_bind_param($stmt, 'si', $assignUid, $id);
|
||||
mysqli_stmt_execute($stmt);
|
||||
echo json_encode(['code' => 0, 'msg' => '工单分发成功']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 一键标记当前用户全部待处理工单为已读
|
||||
if ($action === 'mark_all_read') {
|
||||
$uid = $post['uid'] ?? '';
|
||||
$sql = "UPDATE work_order SET is_read=1 WHERE assign_uid=? AND status=1";
|
||||
$stmt = mysqli_prepare($connWork, $sql);
|
||||
mysqli_stmt_bind_param($stmt, "s", $uid);
|
||||
mysqli_stmt_execute($stmt);
|
||||
echo json_encode(['code' => 0, 'msg' => '已全部标记为已读']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 语言切换
|
||||
if ($action === 'save_lang') {
|
||||
$lang = $post['lang'] ?? 'zh';
|
||||
$sql = "UPDATE sys_config SET v=? WHERE k='lang'";
|
||||
$stmt = mysqli_prepare($connConf, $sql);
|
||||
mysqli_stmt_bind_param($stmt, "s", $lang);
|
||||
mysqli_stmt_execute($stmt);
|
||||
echo json_encode(['code' => 0, 'msg' => '成功']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 同步Prometheus实时告警,自动清理恢复告警
|
||||
if ($action === "sync_prom_alerts") {
|
||||
$promUrl = "http://10.150.117.190:9090/api/v1/alerts";
|
||||
$resp = @file_get_contents($promUrl);
|
||||
if($resp === false){
|
||||
echo json_encode(["code"=>500,"msg"=>"无法连接Prometheus接口"],JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$promData = json_decode($resp,true);
|
||||
if(!isset($promData['data']['alerts'])){
|
||||
echo json_encode(["code"=>500,"msg"=>"Prometheus返回数据异常"],JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 1. 先将表内所有告警标记为已恢复
|
||||
mysqli_query($connConf,"UPDATE alert_firing SET status='resolved'");
|
||||
|
||||
// 2. 遍历当前Prometheus活跃告警,写入/更新
|
||||
$alerts = $promData['data']['alerts'] ?? [];
|
||||
foreach($alerts as $item){
|
||||
$alertName = $item['labels']['alertname'] ?? "unknown";
|
||||
$instance = $item['labels']['instance'] ?? "unknown";
|
||||
$alertId = $alertName."_".$instance;
|
||||
$sql = "INSERT INTO alert_firing(alert_id,alert_name,instance,status,receive_time) VALUES (?,?,?,'firing',NOW()) ON DUPLICATE KEY UPDATE status='firing',receive_time=NOW()";
|
||||
$stmt = mysqli_prepare($connConf,$sql);
|
||||
mysqli_stmt_bind_param($stmt,"sss",$alertId,$alertName,$instance);
|
||||
mysqli_stmt_execute($stmt);
|
||||
}
|
||||
$syncNum = count($alerts);
|
||||
|
||||
// 同步完成后自动执行工单状态更新
|
||||
$syncWorkUrl = $_SERVER['REQUEST_SCHEME'] . "://" . $_SERVER['HTTP_HOST'] . $_SERVER['SCRIPT_NAME'] . "?action=auto_sync_workorder_status";
|
||||
@file_get_contents($syncWorkUrl);
|
||||
|
||||
echo json_encode([
|
||||
"code"=>0,
|
||||
"msg"=>"Prometheus告警同步完成,已自动同步工单状态",
|
||||
"active_alert_total"=>$syncNum
|
||||
],JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 定时同步:告警恢复自动完工单
|
||||
if($action === "sync_alert_auto_close"){
|
||||
// 1、拉取仪表盘实时未恢复告警
|
||||
$alertApi = "./index.php?act=active_firing";
|
||||
$raw = @file_get_contents($alertApi);
|
||||
$activeList = json_decode($raw, true) ?: [];
|
||||
$activeKeys = [];
|
||||
foreach($activeList as $item){
|
||||
$activeKeys[] = $item['alert_name'] . "|" . $item['instance'];
|
||||
}
|
||||
// 2、查询所有未完成、绑定告警的工单
|
||||
$sql = "SELECT id, relate_alert_id FROM work_order WHERE status IN(1,2) AND relate_alert_id <> ''";
|
||||
$res = mysqli_query($connWork, $sql);
|
||||
while($row = mysqli_fetch_assoc($res)){
|
||||
// 工单关联告警不在当前活跃列表=已恢复,自动设为完成
|
||||
if(!in_array($row['relate_alert_id'], $activeKeys)){
|
||||
$updateSql = "UPDATE work_order SET status=3, update_time=NOW() WHERE id = ".(int)$row['id'];
|
||||
mysqli_query($connWork, $updateSql);
|
||||
}
|
||||
}
|
||||
echo json_encode(['code'=>0, 'msg'=>'同步完成']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ==================== 全局通知统一函数 ====================
|
||||
function sendWorkOrderNotify($connConf, $connWork, $orderId, $notifyUserStr, $channelStr)
|
||||
{
|
||||
if (empty($notifyUserStr) || empty($channelStr)) return;
|
||||
$userArr = explode(",", $notifyUserStr);
|
||||
$channelArr = explode(",", $channelStr);
|
||||
|
||||
// 读取完整通知配置(邮箱+钉钉+企微)
|
||||
$cfgRes = mysqli_query($connConf, "SELECT * FROM sys_smtp_config WHERE id=1");
|
||||
$notifyCfg = $cfgRes ? mysqli_fetch_assoc($cfgRes) : [];
|
||||
if(empty($notifyCfg)) return;
|
||||
|
||||
// 获取工单完整信息
|
||||
$orderRes = mysqli_query($connWork, "SELECT * FROM work_order WHERE id=$orderId");
|
||||
$order = $orderRes ? mysqli_fetch_assoc($orderRes) : [];
|
||||
$title = $order['title'];
|
||||
$content = $order['content'];
|
||||
$relateId = $order['relate_alert_id'] ?: "无";
|
||||
|
||||
$msgText = "【运维工单通知】\n工单ID:$orderId\n关联告警:$relateId\n标题:$title\n详情:$content";
|
||||
|
||||
foreach ($channelArr as $ch) {
|
||||
$ch = trim($ch);
|
||||
switch ($ch) {
|
||||
case "mail":
|
||||
sendMailNotify($notifyCfg, $connWork, $userArr, $title, $content, $relateId, $orderId);
|
||||
break;
|
||||
case "dingtalk":
|
||||
sendDingNotify($notifyCfg, $msgText);
|
||||
break;
|
||||
case "wecom":
|
||||
sendWecomNotify($notifyCfg, $msgText);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 邮件发送
|
||||
function sendMailNotify($smtp, $connWork, $uidList, $title, $content, $relateId, $orderId)
|
||||
{
|
||||
if (empty($smtp['smtp_host']) || empty($smtp['smtp_account'])) return;
|
||||
$emailList = [];
|
||||
foreach ($uidList as $uid) {
|
||||
$uid = trim($uid);
|
||||
if (empty($uid)) continue;
|
||||
$res = mysqli_query($connWork, "SELECT email FROM sys_user WHERE uid='$uid'");
|
||||
$u = mysqli_fetch_assoc($res);
|
||||
if (!empty($u['email'])) $emailList[] = $u['email'];
|
||||
}
|
||||
if (empty($emailList)) return;
|
||||
$to = implode(",", array_unique($emailList));
|
||||
$subject = "【工单通知】#$orderId $title";
|
||||
$body = "<h3>工单 #$orderId</h3>
|
||||
<p>关联告警ID:$relateId</p>
|
||||
<p>标题:$title</p>
|
||||
<pre style='background:#f5f5f5;padding:10px'>$content</pre>";
|
||||
// 此处接入PHPMailer发送逻辑
|
||||
}
|
||||
|
||||
// 钉钉机器人推送
|
||||
function sendDingNotify($cfg, $text)
|
||||
{
|
||||
$webhook = $cfg['ding_webhook'] ?? '';
|
||||
$secret = $cfg['ding_secret'] ?? '';
|
||||
if(empty($webhook)) return;
|
||||
$data = json_encode([
|
||||
"msgtype" => "text",
|
||||
"text" => ["content" => $text]
|
||||
]);
|
||||
$opts = ["http" => ["method" => "POST", "header" => "Content-Type:application/json", "content" => $data]];
|
||||
file_get_contents($webhook, false, stream_context_create($opts));
|
||||
}
|
||||
|
||||
// 企业微信机器人推送
|
||||
function sendWecomNotify($cfg, $text)
|
||||
{
|
||||
$webhook = $cfg['wecom_webhook'] ?? '';
|
||||
if(empty($webhook)) return;
|
||||
$data = json_encode([
|
||||
"msgtype" => "text",
|
||||
"text" => ["content" => $text]
|
||||
]);
|
||||
$opts = ["http" => ["method" => "POST", "header" => "Content-Type:application/json", "content" => $data]];
|
||||
file_get_contents($webhook, false, stream_context_create($opts));
|
||||
}
|
||||
|
||||
// 无匹配动作
|
||||
echo json_encode(['code' => 400, 'msg' => '无效请求动作']);
|
||||
mysqli_close($connWork);
|
||||
mysqli_close($connConf);
|
||||
exit;
|
||||
?>
|
||||
Reference in New Issue
Block a user