提交 f3893286 authored 作者: 陈泽健's avatar 陈泽健

feat(service-monitor): 左侧菜单布局改造与报告列表独立页

- 新增 base.html 共享基础模板:左侧固定侧边栏 + 顶部条 + 内容区
- 侧边栏 4 个菜单项:监测目标/巡检报告/定时任务/目标管理
- 移动端 768px 以下汉堡菜单滑出
- index.html 继承 base.html,只保留目标卡片
- 新增 reports.html 巡检报告独立页,支持按目标筛选
- targets.html / run.html / report.html 继承 base.html
- routes.py 新增 page_reports 路由,所有路由增加 active_menu 参数
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 8a63c076
...@@ -22,7 +22,7 @@ from flask import ( ...@@ -22,7 +22,7 @@ from flask import (
Response, redirect, url_for, stream_with_context, Response, redirect, url_for, stream_with_context,
) )
from .services import target_service, report_service, runner_service from .services import target_service, report_service, runner_service, schedule_service
logger = logging.getLogger("service_monitor.routes") logger = logging.getLogger("service_monitor.routes")
...@@ -61,16 +61,16 @@ def _require_admin_json(): ...@@ -61,16 +61,16 @@ def _require_admin_json():
@bp.route('/service-monitor') @bp.route('/service-monitor')
def page_index(): def page_index():
"""监测主页:目标概览 + 最近报告。""" """监测主页:目标概览。"""
if 'user' not in session: if 'user' not in session:
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
user = _current_user() user = _current_user()
targets = target_service.list_targets(role=user.get('role', '')) targets = target_service.list_targets(role=user.get('role', ''))
reports = report_service.list_reports(limit=20)
return render_template( return render_template(
'service_monitor/index.html', 'service_monitor/index.html',
user=user, targets=targets, reports=reports, user=user, targets=targets,
is_admin=(user.get('role') == 'admin'), is_admin=(user.get('role') == 'admin'),
active_menu='targets',
) )
...@@ -83,7 +83,43 @@ def page_targets(): ...@@ -83,7 +83,43 @@ def page_targets():
if user.get('role') != 'admin': if user.get('role') != 'admin':
return redirect(url_for('service-monitor.page_index')) return redirect(url_for('service-monitor.page_index'))
targets = target_service.list_targets(role='admin') targets = target_service.list_targets(role='admin')
return render_template('service_monitor/targets.html', user=user, targets=targets) return render_template(
'service_monitor/targets.html', user=user, targets=targets,
is_admin=True, active_menu='manage',
)
@bp.route('/service-monitor/reports')
def page_reports():
"""巡检报告列表页(所有登录用户可看)。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
target_id = request.args.get('target_id')
reports = report_service.list_reports(target_id=target_id, limit=50)
targets = target_service.list_targets(role=user.get('role', ''))
return render_template(
'service_monitor/reports.html',
user=user, reports=reports, targets=targets,
is_admin=(user.get('role') == 'admin'),
active_menu='reports',
)
@bp.route('/service-monitor/schedule')
def page_schedule():
"""定时任务管理页(所有登录用户可看,操作仅管理员)。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
schedules = schedule_service.list_schedules()
targets = target_service.list_targets(role=user.get('role', ''))
return render_template(
'service_monitor/schedule.html',
user=user, schedules=schedules, targets=targets,
is_admin=(user.get('role') == 'admin'),
active_menu='schedule',
)
@bp.route('/service-monitor/run/<target_id>') @bp.route('/service-monitor/run/<target_id>')
...@@ -99,7 +135,8 @@ def page_run(target_id): ...@@ -99,7 +135,8 @@ def page_run(target_id):
return redirect(url_for('service-monitor.page_index')) return redirect(url_for('service-monitor.page_index'))
suite = request.args.get('suite', 'quick') suite = request.args.get('suite', 'quick')
return render_template('service_monitor/run.html', return render_template('service_monitor/run.html',
user=user, target=target, suite=suite) user=user, target=target, suite=suite,
is_admin=True, active_menu='targets')
@bp.route('/service-monitor/report/<report_id>') @bp.route('/service-monitor/report/<report_id>')
...@@ -111,7 +148,11 @@ def page_report(report_id): ...@@ -111,7 +148,11 @@ def page_report(report_id):
report = report_service.get_report(report_id) report = report_service.get_report(report_id)
if not report: if not report:
return redirect(url_for('service-monitor.page_index')) return redirect(url_for('service-monitor.page_index'))
return render_template('service_monitor/report.html', user=user, report=report) return render_template(
'service_monitor/report.html', user=user, report=report,
is_admin=(user.get('role') == 'admin'),
active_menu='reports',
)
# ============================================================ # ============================================================
...@@ -290,3 +331,72 @@ def api_fix(): ...@@ -290,3 +331,72 @@ def api_fix():
if guard: if guard:
return guard return guard
return jsonify({"success": False, "message": "修复能力开发中,敬请期待"}) return jsonify({"success": False, "message": "修复能力开发中,敬请期待"})
# ============================================================
# API:定时任务(管理员操作,登录可查看)
# ============================================================
@bp.route('/api/service-monitor/schedules', methods=['GET'])
def api_list_schedules():
guard = _require_login_json()
if guard:
return guard
schedules = schedule_service.list_schedules()
return jsonify({"success": True, "schedules": schedules})
@bp.route('/api/service-monitor/schedules', methods=['POST'])
def api_create_schedule():
guard = _require_admin_json()
if guard:
return guard
try:
sched = schedule_service.create_schedule(
request.get_json(force=True) or {},
created_by=_current_user().get('username', 'admin'),
)
# 通知调度器重新加载
schedule_service.reload_job(sched['id'])
return jsonify({"success": True, "schedule": sched})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
@bp.route('/api/service-monitor/schedules/<schedule_id>', methods=['PUT'])
def api_update_schedule(schedule_id):
guard = _require_admin_json()
if guard:
return guard
try:
sched = schedule_service.update_schedule(
schedule_id, request.get_json(force=True) or {}
)
schedule_service.reload_job(schedule_id)
return jsonify({"success": True, "schedule": sched})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
@bp.route('/api/service-monitor/schedules/<schedule_id>', methods=['DELETE'])
def api_delete_schedule(schedule_id):
guard = _require_admin_json()
if guard:
return guard
ok = schedule_service.delete_schedule(schedule_id)
if ok:
schedule_service.reload_job(schedule_id) # 会移除 job
return jsonify({"success": ok})
@bp.route('/api/service-monitor/schedules/<schedule_id>/toggle', methods=['POST'])
def api_toggle_schedule(schedule_id):
guard = _require_admin_json()
if guard:
return guard
try:
sched = schedule_service.toggle_enabled(schedule_id)
schedule_service.reload_job(schedule_id)
return jsonify({"success": True, "schedule": sched})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5">
<title>{% block title %}服务监测{% endblock %} - 运行维护平台</title>
<style>
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--green: #16a34a;
--yellow: #d97706;
--red: #dc2626;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-700: #374151;
--gray-900: #111827;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif;
background: #f3f4f6;
min-height: 100vh;
}
/* 侧边栏 */
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 200px;
height: 100vh;
background: linear-gradient(180deg, #1e293b 0%, #334155 100%);
z-index: 100;
transition: transform 0.3s ease;
}
.sidebar-brand {
color: #fff;
font-size: 18px;
font-weight: 700;
padding: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.sidebar-nav {
padding: 16px 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
color: #94a3b8;
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
}
.nav-item:hover {
background: rgba(255,255,255,0.05);
color: #fff;
}
.nav-item.active {
background: var(--primary);
color: #fff;
}
.nav-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
/* 顶部条 */
.topbar {
position: fixed;
left: 200px;
top: 0;
right: 0;
height: 56px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 99;
}
.topbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.hamburger {
display: none;
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.35);
color: #fff;
font-size: 18px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
}
.topbar-home {
background: rgba(255,255,255,0.2);
color: #fff;
border: 1px solid rgba(255,255,255,0.35);
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
}
.topbar-home:hover {
background: rgba(255,255,255,0.3);
}
.topbar-user {
color: #fff;
display: flex;
align-items: center;
gap: 14px;
font-size: 14px;
}
.btn-logout {
background: rgba(255,255,255,0.15);
color: #fff;
border: 1px solid rgba(255,255,255,0.3);
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
}
.btn-logout:hover {
background: rgba(255,255,255,0.25);
}
/* 主内容区 */
.main-content {
margin-left: 200px;
padding: 84px 24px 28px;
min-height: 100vh;
}
/* 通用组件样式 */
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 18px;
font-weight: 700;
color: var(--gray-900);
display: flex;
align-items: center;
gap: 8px;
}
.btn-add {
background: var(--primary);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.btn-add:hover {
background: var(--primary-hover);
}
.empty {
text-align: center;
padding: 40px;
color: var(--gray-500);
}
.footer {
text-align: center;
padding: 20px;
color: var(--gray-500);
font-size: 12px;
}
/* 移动端响应式 */
@media screen and (max-width: 768px) {
.sidebar {
transform: translateX(-200px);
}
.sidebar.open {
transform: translateX(0);
}
.topbar {
left: 0;
}
.main-content {
margin-left: 0;
padding: 84px 14px 28px;
}
.hamburger {
display: block;
}
.topbar-home {
display: none;
}
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- 侧边栏 -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">🛠️ 运维</div>
<nav class="sidebar-nav">
<a href="/service-monitor" class="nav-item {{ 'active' if active_menu == 'targets' }}">
<span class="nav-icon">📊</span>
<span>监测目标</span>
</a>
<a href="/service-monitor/reports" class="nav-item {{ 'active' if active_menu == 'reports' }}">
<span class="nav-icon">📋</span>
<span>巡检报告</span>
</a>
<a href="/service-monitor/schedule" class="nav-item {{ 'active' if active_menu == 'schedule' }}">
<span class="nav-icon"></span>
<span>定时任务</span>
</a>
{% if is_admin %}
<a href="/service-monitor/targets" class="nav-item {{ 'active' if active_menu == 'manage' }}">
<span class="nav-icon">⚙️</span>
<span>目标管理</span>
</a>
{% endif %}
</nav>
</aside>
<!-- 顶部条 -->
<header class="topbar">
<div class="topbar-left">
<button class="hamburger" id="hamburger" onclick="toggleSidebar()"></button>
<a href="/" class="topbar-home">🏠 返回首页</a>
</div>
<div class="topbar-user">
<span>{{ user.username }}({{ '管理员' if is_admin else '用户' }})</span>
<button class="btn-logout" onclick="handleLogout()">🚪 退出</button>
</div>
</header>
<!-- 主内容区 -->
<main class="main-content">
{% block content %}{% endblock %}
</main>
{% if not no_footer %}
<div class="footer">运行维护平台 · 服务监测模块</div>
{% endif %}
<script>
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
}
// 点击遮罩关闭侧边栏
document.addEventListener('click', function(e) {
const sidebar = document.getElementById('sidebar');
const hamburger = document.getElementById('hamburger');
if (sidebar.classList.contains('open') &&
!sidebar.contains(e.target) &&
!hamburger.contains(e.target)) {
sidebar.classList.remove('open');
}
});
async function handleLogout() {
try {
await fetch('/logout', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include'
});
} catch (e) {}
window.location.href = '/login';
}
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
\ No newline at end of file
<!DOCTYPE html> {% extends "service_monitor/base.html" %}
<html lang="zh-CN"> {% block title %}监测目标{% endblock %}
<head> {% block content %}
<meta charset="UTF-8"> <div class="section-head">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5"> <div class="section-title">📊 监测目标</div>
<title>服务监测 - 运行维护平台</title> {% if is_admin %}
<style> <a href="/service-monitor/targets" class="btn-add">+ 目标管理</a>
:root { {% endif %}
--primary: #2563eb; </div>
--primary-hover: #1d4ed8;
--green: #16a34a;
--yellow: #d97706;
--red: #dc2626;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-500: #6b7280;
--gray-700: #374151;
--gray-900: #111827;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif;
background: #f3f4f6;
min-height: 100vh;
display: flex; flex-direction: column;
}
/* 顶部导航 — 与平台首页统一 */
.navbar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 14px 24px; display: flex; align-items: center; justify-content: space-between;
}
.navbar-left { display: flex; align-items: center; gap: 14px; }
.navbar-brand { color: #fff; font-size: 18px; font-weight: 700; text-decoration: none; }
.navbar-module { color: rgba(255,255,255,.7); font-size: 14px; }
.navbar-home {
background: rgba(255,255,255,.2); color: #fff; border: 1px solid rgba(255,255,255,.35);
padding: 6px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; text-decoration: none;
white-space: nowrap;
}
.navbar-home:hover { background: rgba(255,255,255,.3); }
.navbar-user { color: #fff; display: flex; align-items: center; gap: 14px; font-size: 14px; }
.btn-logout {
background: rgba(255,255,255,.15); color: #fff; border: 1px solid rgba(255,255,255,.3);
padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.btn-logout:hover { background: rgba(255,255,255,.25); }
.container { max-width: 1100px; margin: 0 auto; padding: 28px 24px; flex: 1; width: 100%; } {% if targets %}
<div class="target-grid">
/* 区块标题 */ {% for t in targets %}
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } <div class="target-card">
.section-title { font-size: 18px; font-weight: 700; color: var(--gray-900); display: flex; align-items: center; gap: 8px; } <div class="target-card-head">
.btn-add { <div class="target-icon">{{ '🖥️' if t.type == 'local' else '🌐' }}</div>
background: var(--primary); color: #fff; border: none; padding: 8px 16px; <div class="target-name">{{ t.name }}</div>
border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; text-decoration: none; {% if t.built_in %}<span class="target-badge">内置</span>{% endif %}
}
.btn-add:hover { background: var(--primary-hover); }
/* 目标卡片网格 */
.target-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 36px; }
.target-card {
background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,.06);
border: 1px solid var(--gray-200);
}
.target-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.target-icon { font-size: 28px; }
.target-name { font-size: 16px; font-weight: 700; color: var(--gray-900); }
.target-meta { font-size: 13px; color: var(--gray-500); margin-bottom: 14px; }
.target-actions { display: flex; gap: 8px; }
.btn-suite {
flex: 1; padding: 8px 0; border-radius: 8px; font-size: 13px; font-weight: 600;
text-align: center; text-decoration: none; cursor: pointer; border: none;
}
.btn-quick { background: var(--gray-100); color: var(--gray-700); }
.btn-quick:hover { background: var(--gray-200); }
.btn-full { background: #eef2ff; color: var(--primary); }
.btn-full:hover { background: #e0e7ff; }
.target-badge {
display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 4px;
background: var(--gray-100); color: var(--gray-500); margin-left: auto;
}
/* 报告列表 */
.report-list { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.06); border: 1px solid var(--gray-200); }
.report-row {
display: grid; grid-template-columns: 2fr 1fr 2fr 2fr 1fr; gap: 12px; align-items: center;
padding: 14px 18px; border-bottom: 1px solid var(--gray-100); text-decoration: none;
}
.report-row:last-child { border-bottom: none; }
.report-row:hover { background: var(--gray-50); }
.report-target { font-weight: 600; color: var(--gray-900); }
.report-suite { font-size: 13px; color: var(--gray-500); }
.report-summary { display: flex; gap: 10px; font-size: 13px; }
.sm-ok { color: var(--green); } .sm-warn { color: var(--yellow); } .sm-crit { color: var(--red); }
.report-time { font-size: 13px; color: var(--gray-500); }
.btn-view {
background: var(--gray-100); color: var(--gray-700); padding: 6px 14px;
border-radius: 6px; font-size: 13px; font-weight: 600; text-decoration: none; text-align: center;
}
.btn-view:hover { background: var(--gray-200); }
.empty { text-align: center; padding: 40px; color: var(--gray-500); }
.footer { text-align: center; padding: 20px; color: var(--gray-500); font-size: 12px; }
@media screen and (max-width: 1024px) { .target-grid { grid-template-columns: repeat(2, 1fr); } }
@media screen and (max-width: 768px) {
.container { padding: 20px 14px; }
.target-grid { grid-template-columns: 1fr; }
.navbar-module { display: none; }
.navbar-home, .btn-logout { min-height: 44px; font-size: 16px; }
.report-row { grid-template-columns: 1fr 1fr; gap: 6px; }
.report-summary { font-size: 12px; }
}
</style>
</head>
<body>
<div class="navbar">
<div class="navbar-left">
<a href="/" class="navbar-brand">🛠️ 运行维护平台</a>
<span class="navbar-module">/ 服务监测</span>
<a href="/" class="navbar-home">🏠 返回首页</a>
</div> </div>
<div class="navbar-user"> <div class="target-meta">
<span>{{ user.username }}({{ '管理员' if is_admin else '用户' }})</span> {{ '本机容器' if t.type == 'local' else (t.host ~ ':' ~ t.port ~ ' ' ~ t.username) }}
{% if is_admin %}<a href="/service-monitor/targets" class="navbar-home">⚙️ 目标管理</a>{% endif %}
<button class="btn-logout" onclick="handleLogout()">🚪 退出</button>
</div> </div>
</div> {% if is_admin %}
<div class="target-actions">
<div class="container"> <a href="/service-monitor/run/{{ t.id }}?suite=quick" class="btn-suite btn-quick">⚡ 快速巡检</a>
<!-- 监测目标 --> <a href="/service-monitor/run/{{ t.id }}?suite=full" class="btn-suite btn-full">🔍 全量巡检</a>
<div class="section-head">
<div class="section-title">📊 监测目标</div>
{% if is_admin %}
<a href="/service-monitor/targets" class="btn-add">+ 目标管理</a>
{% endif %}
</div>
{% if targets %}
<div class="target-grid">
{% for t in targets %}
<div class="target-card">
<div class="target-card-head">
<div class="target-icon">{{ '🖥️' if t.type == 'local' else '🌐' }}</div>
<div class="target-name">{{ t.name }}</div>
{% if t.built_in %}<span class="target-badge">内置</span>{% endif %}
</div>
<div class="target-meta">
{{ '本机容器' if t.type == 'local' else (t.host ~ ':' ~ t.port ~ ' ' ~ t.username) }}
</div>
{% if is_admin %}
<div class="target-actions">
<a href="/service-monitor/run/{{ t.id }}?suite=quick" class="btn-suite btn-quick">⚡ 快速巡检</a>
<a href="/service-monitor/run/{{ t.id }}?suite=full" class="btn-suite btn-full">🔍 全量巡检</a>
</div>
{% else %}
<div class="target-actions">
<span class="btn-suite btn-quick" style="cursor:default; opacity:.5;">⚡ 快速巡检</span>
<span class="btn-suite btn-full" style="cursor:default; opacity:.5;">🔍 全量巡检</span>
</div>
{% endif %}
</div>
{% endfor %}
</div> </div>
{% else %} {% else %}
<div class="empty">📭 暂无监测目标</div> <div class="target-actions">
{% endif %} <span class="btn-suite btn-quick" style="cursor:default; opacity:.5;">⚡ 快速巡检</span>
<span class="btn-suite btn-full" style="cursor:default; opacity:.5;">🔍 全量巡检</span>
<!-- 最近巡检报告 -->
<div class="section-head">
<div class="section-title">📋 最近巡检报告</div>
</div> </div>
{% if reports %}
<div class="report-list">
{% for r in reports %}
<a href="/service-monitor/report/{{ r.id }}" class="report-row">
<span class="report-target">{{ r.target_name }}</span>
<span class="report-suite">{{ '快速' if r.suite == 'quick' else '全量' }}</span>
<span class="report-summary">
<span class="sm-ok">✓{{ r.summary.正常 if r.summary else 0 }}</span>
<span class="sm-warn">⚠{{ r.summary.警告 if r.summary else 0 }}</span>
<span class="sm-crit">✗{{ r.summary.严重 if r.summary else 0 }}</span>
</span>
<span class="report-time">{{ (r.started_at or '')[5:16].replace('T', ' ') }}</span>
<span class="btn-view">查看</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty">📭 暂无巡检报告,点击目标卡片开始巡检</div>
{% endif %} {% endif %}
</div> </div>
{% endfor %}
</div>
{% else %}
<div class="empty">📭 暂无监测目标</div>
{% endif %}
{% endblock %}
<div class="footer">运行维护平台 · 服务监测模块</div> {% block extra_css %}
<style>
/* 目标卡片网格 */
.target-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 36px; }
.target-card {
background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,.06);
border: 1px solid var(--gray-200);
}
.target-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.target-icon { font-size: 28px; }
.target-name { font-size: 16px; font-weight: 700; color: var(--gray-900); }
.target-meta { font-size: 13px; color: var(--gray-500); margin-bottom: 14px; }
.target-actions { display: flex; gap: 8px; }
.btn-suite {
flex: 1; padding: 8px 0; border-radius: 8px; font-size: 13px; font-weight: 600;
text-align: center; text-decoration: none; cursor: pointer; border: none;
}
.btn-quick { background: var(--gray-100); color: var(--gray-700); }
.btn-quick:hover { background: var(--gray-200); }
.btn-full { background: #eef2ff; color: var(--primary); }
.btn-full:hover { background: #e0e7ff; }
.target-badge {
display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 4px;
background: var(--gray-100); color: var(--gray-500); margin-left: auto;
}
<script> @media screen and (max-width: 1024px) { .target-grid { grid-template-columns: repeat(2, 1fr); } }
async function handleLogout() { @media screen and (max-width: 768px) { .target-grid { grid-template-columns: 1fr; } }
try { await fetch('/logout', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include'}); } catch(e) {} </style>
window.location.href = '/login'; {% endblock %}
} \ No newline at end of file
</script>
</body>
</html>
<!DOCTYPE html> {% extends "service_monitor/base.html" %}
<html lang="zh-CN"> {% block title %}巡检报告{% endblock %}
<head>
<meta charset="UTF-8"> {% block extra_css %}
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5"> <style>
<title>巡检报告 - 服务监测</title> .container { max-width: 960px; }
<style>
:root { /* 报告头 */
--primary:#2563eb; --primary-hover:#1d4ed8; .report-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; flex-wrap: wrap; gap: 12px; }
--green:#16a34a; --yellow:#d97706; --red:#dc2626; .report-meta h2 { font-size: 20px; color: var(--gray-900); margin-bottom: 4px; }
--gray-50:#f9fafb; --gray-100:#f3f4f6; --gray-200:#e5e7eb; .report-meta .sub { font-size: 13px; color: var(--gray-500); }
--gray-500:#6b7280; --gray-700:#374151; --gray-900:#111827; .export-wrap { position: relative; }
} .btn-export { background: var(--primary); color: #fff; border: none; padding: 9px 18px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
* { box-sizing:border-box; margin:0; padding:0; } .export-menu { display: none; position: absolute; right: 0; top: 44px; background: #fff; border: 1px solid var(--gray-200); border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.12); overflow: hidden; z-index: 10; }
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Microsoft YaHei",sans-serif; background:#f3f4f6; min-height:100vh; } .export-menu.show { display: block; }
.navbar { background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); padding:14px 24px; display:flex; align-items:center; justify-content:space-between; } .export-menu a { display: block; padding: 10px 20px; font-size: 14px; color: var(--gray-700); text-decoration: none; }
.navbar-left { display:flex; align-items:center; gap:14px; } .export-menu a:hover { background: var(--gray-50); }
.navbar-brand { color:#fff; font-size:18px; font-weight:700; text-decoration:none; }
.navbar-module { color:rgba(255,255,255,.7); font-size:14px; } /* 汇总卡片 */
.navbar-home { background:rgba(255,255,255,.2); color:#fff; border:1px solid rgba(255,255,255,.35); padding:6px 14px; border-radius:8px; font-size:13px; font-weight:600; text-decoration:none; } .summary-cards { display: grid; grid-template-columns: repeat(4,1fr); gap: 12px; margin-bottom: 24px; }
.navbar-user { color:#fff; font-size:14px; } .sum-card { background: #fff; border-radius: 12px; padding: 18px; text-align: center; border: 1px solid var(--gray-200); }
.sum-card .num { font-size: 28px; font-weight: 700; }
.container { max-width:960px; margin:0 auto; padding:28px 24px; } .sum-card .label { font-size: 13px; color: var(--gray-500); margin-top: 4px; }
.sum-total .num { color: var(--gray-900); }
/* 报告头 */ .sum-ok .num { color: var(--green); }
.report-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; flex-wrap:wrap; gap:12px; } .sum-warn .num { color: var(--yellow); }
.report-meta h2 { font-size:20px; color:var(--gray-900); margin-bottom:4px; } .sum-crit .num { color: var(--red); }
.report-meta .sub { font-size:13px; color:var(--gray-500); }
.export-wrap { position:relative; } /* 异常项 */
.btn-export { background:var(--primary); color:#fff; border:none; padding:9px 18px; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; } .section-title { font-size: 16px; font-weight: 700; color: var(--gray-900); margin: 24px 0 12px; display: flex; align-items: center; gap: 8px; }
.export-menu { display:none; position:absolute; right:0; top:44px; background:#fff; border:1px solid var(--gray-200); border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,.12); overflow:hidden; z-index:10; } .abnormal-box { background: #fff; border-radius: 12px; border: 1px solid var(--gray-200); overflow: hidden; }
.export-menu.show { display:block; } .abnormal-row { display: grid; grid-template-columns: 24px 2fr 1fr 1fr 1fr auto; gap: 12px; align-items: center; padding: 12px 18px; border-bottom: 1px solid var(--gray-100); font-size: 14px; }
.export-menu a { display:block; padding:10px 20px; font-size:14px; color:var(--gray-700); text-decoration:none; } .abnormal-row:last-child { border-bottom: none; }
.export-menu a:hover { background:var(--gray-50); } .ab-icon { text-align: center; }
.ab-crit { color: var(--red); } .ab-warn { color: var(--yellow); }
/* 汇总卡片 */ .ab-name { font-weight: 600; color: var(--gray-900); }
.summary-cards { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:24px; } .ab-module { font-size: 12px; color: var(--gray-500); }
.sum-card { background:#fff; border-radius:12px; padding:18px; text-align:center; border:1px solid var(--gray-200); } .ab-value { font-weight: 600; }
.sum-card .num { font-size:28px; font-weight:700; } .ab-threshold { font-size: 13px; color: var(--gray-500); }
.sum-card .label { font-size:13px; color:var(--gray-500); margin-top:4px; } .btn-fix { background: #fef3c7; color: #92400e; border: none; padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; }
.sum-total .num { color:var(--gray-900); }
.sum-ok .num { color:var(--green); } /* 分模块折叠 */
.sum-warn .num { color:var(--yellow); } .module-block { background: #fff; border-radius: 12px; border: 1px solid var(--gray-200); margin-bottom: 12px; overflow: hidden; }
.sum-crit .num { color:var(--red); } .module-header { display: flex; align-items: center; gap: 10px; padding: 14px 18px; cursor: pointer; user-select: none; }
.module-header:hover { background: var(--gray-50); }
/* 异常项 */ .module-toggle { font-size: 12px; color: var(--gray-500); transition: transform .2s; flex-shrink: 0; }
.section-title { font-size:16px; font-weight:700; color:var(--gray-900); margin:24px 0 12px; display:flex; align-items:center; gap:8px; } .module-block.open .module-toggle { transform: rotate(90deg); }
.abnormal-box { background:#fff; border-radius:12px; border:1px solid var(--gray-200); overflow:hidden; } .module-title { font-weight: 700; color: var(--gray-900); flex: 1; }
.abnormal-row { display:grid; grid-template-columns:24px 2fr 1fr 1fr 1fr auto; gap:12px; align-items:center; padding:12px 18px; border-bottom:1px solid var(--gray-100); font-size:14px; } .module-mini { display: flex; gap: 8px; font-size: 13px; }
.abnormal-row:last-child { border-bottom:none; } .module-body { max-height: 0; overflow: hidden; transition: max-height .35s ease; }
.ab-icon { text-align:center; } .module-block.open .module-body { max-height: 5000px; }
.ab-crit { color:var(--red); } .ab-warn { color:var(--yellow); }
.ab-name { font-weight:600; color:var(--gray-900); } /* 检测项行 */
.ab-module { font-size:12px; color:var(--gray-500); } .item-row { display: grid; grid-template-columns: 2fr 1.5fr 1fr 1fr; gap: 12px; padding: 10px 18px; border-top: 1px solid var(--gray-100); font-size: 14px; }
.ab-value { font-weight:600; } .item-name { color: var(--gray-700); }
.ab-threshold { font-size:13px; color:var(--gray-500); } .item-value { color: var(--gray-900); font-weight: 600; word-break: break-all; }
.btn-fix { background:#fef3c7; color:#92400e; border:none; padding:5px 12px; border-radius:6px; font-size:12px; font-weight:600; cursor:pointer; } .item-threshold { color: var(--gray-500); font-size: 13px; }
.item-status { font-weight: 600; }
/* 分模块折叠 */ .stt-正常 { color: var(--green); } .stt-警告 { color: var(--yellow); } .stt-严重 { color: var(--red); }
.module-block { background:#fff; border-radius:12px; border:1px solid var(--gray-200); margin-bottom:12px; overflow:hidden; }
.module-header { display:flex; align-items:center; gap:10px; padding:14px 18px; cursor:pointer; user-select:none; } /* 进程列表特殊样式 */
.module-header:hover { background:var(--gray-50); } .process-row { border-top: 1px solid var(--gray-100); padding: 10px 18px; }
.module-toggle { font-size:12px; color:var(--gray-500); transition:transform .2s; flex-shrink:0; } .process-summary { font-weight: 600; color: var(--gray-900); margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
.module-block.open .module-toggle { transform:rotate(90deg); } .process-toggle-btn { background: var(--gray-100); border: 1px solid var(--gray-200); padding: 3px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; color: var(--gray-700); }
.module-title { font-weight:700; color:var(--gray-900); flex:1; } .process-toggle-btn:hover { background: var(--gray-200); }
.module-mini { display:flex; gap:8px; font-size:13px; } .process-table-wrap { display: none; margin-top: 8px; overflow-x: auto; }
.module-body { max-height:0; overflow:hidden; transition:max-height .35s ease; } .process-table-wrap.show { display: block; }
.module-block.open .module-body { max-height:5000px; } .process-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.process-table th { background: var(--gray-50); padding: 8px 10px; text-align: left; font-weight: 600; color: var(--gray-700); border-bottom: 2px solid var(--gray-200); }
/* 检测项行 */ .process-table td { padding: 6px 10px; border-bottom: 1px solid var(--gray-100); color: var(--gray-900); }
.item-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:12px; padding:10px 18px; border-top:1px solid var(--gray-100); font-size:14px; } .process-table tr:hover td { background: var(--gray-50); }
.item-name { color:var(--gray-700); } .process-table .cmd-cell { font-family: monospace; font-size: 12px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-value { color:var(--gray-900); font-weight:600; word-break:break-all; }
.item-threshold { color:var(--gray-500); font-size:13px; } /* 长值展开 */
.item-status { font-weight:600; } .long-value { cursor: pointer; color: var(--primary); }
.stt-正常 { color:var(--green); } .stt-警告 { color:var(--yellow); } .stt-严重 { color:var(--red); } .long-value .truncated { display: inline; }
.long-value .full { display: none; }
/* 进程列表特殊样式 */ .long-value.expanded .truncated { display: none; }
.process-row { border-top:1px solid var(--gray-100); padding:10px 18px; } .long-value.expanded .full { display: inline; white-space: pre-wrap; word-break: break-all; }
.process-summary { font-weight:600; color:var(--gray-900); margin-bottom:8px; display:flex; align-items:center; gap:8px; }
.process-toggle-btn { background:var(--gray-100); border:1px solid var(--gray-200); padding:3px 10px; border-radius:6px; font-size:12px; cursor:pointer; color:var(--gray-700); } @media screen and (max-width: 768px) {
.process-toggle-btn:hover { background:var(--gray-200); } .summary-cards { grid-template-columns: repeat(2,1fr); }
.process-table-wrap { display:none; margin-top:8px; overflow-x:auto; } .abnormal-row { grid-template-columns: 24px 1fr auto; gap: 6px; }
.process-table-wrap.show { display:block; } .abnormal-row .ab-threshold, .abnormal-row .ab-module { display: none; }
.process-table { width:100%; border-collapse:collapse; font-size:13px; } .item-row { grid-template-columns: 1fr 1fr; gap: 6px; }
.process-table th { background:var(--gray-50); padding:8px 10px; text-align:left; font-weight:600; color:var(--gray-700); border-bottom:2px solid var(--gray-200); } .process-table { font-size: 11px; }
.process-table td { padding:6px 10px; border-bottom:1px solid var(--gray-100); color:var(--gray-900); } .process-table .cmd-cell { max-width: 150px; }
.process-table tr:hover td { background:var(--gray-50); } }
.process-table .cmd-cell { font-family:monospace; font-size:12px; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } </style>
{% endblock %}
/* 长值展开 */
.long-value { cursor:pointer; color:var(--primary); } {% block content %}
.long-value .truncated { display:inline; } <div class="container">
.long-value .full { display:none; } <div class="report-head">
.long-value.expanded .truncated { display:none; } <div class="report-meta">
.long-value.expanded .full { display:inline; white-space:pre-wrap; word-break:break-all; } <h2>{{ '🖥️' if report.target_type == 'local' else '🌐' }} {{ report.target_name }}</h2>
<div class="sub">
{{ '⚡ 快速巡检' if report.suite == 'quick' else '🔍 全量巡检' }} ·
{{ (report.started_at or '').replace('T', ' ') }} ~ {{ (report.finished_at or '')[11:19] }}
</div>
</div>
<div class="export-wrap">
<button class="btn-export" onclick="toggleExport()">⬇ 导出 ▾</button>
<div class="export-menu" id="export-menu">
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=md">导出 Markdown</a>
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=json">导出 JSON</a>
</div>
</div>
</div>
.footer { text-align:center; padding:20px; color:var(--gray-500); font-size:12px; } <!-- 汇总卡片 -->
{% set s = report.summary or {} %}
<div class="summary-cards">
<div class="sum-card sum-total"><div class="num">{{ s.total or 0 }}</div><div class="label">总检测项</div></div>
<div class="sum-card sum-ok"><div class="num">{{ s['正常'] or 0 }}</div><div class="label">正常</div></div>
<div class="sum-card sum-warn"><div class="num">{{ s['警告'] or 0 }}</div><div class="label">警告</div></div>
<div class="sum-card sum-crit"><div class="num">{{ s['严重'] or 0 }}</div><div class="label">严重</div></div>
</div>
@media screen and (max-width:768px) { <!-- 异常项置顶 -->
.container { padding:20px 14px; } {% set ns = namespace(abnormal=[]) %}
.navbar-module { display:none; } {% for m in report.modules %}
.summary-cards { grid-template-columns:repeat(2,1fr); } {% for it in m['items'] %}
.abnormal-row { grid-template-columns:24px 1fr auto; gap:6px; } {% if it.status in ['警告', '严重'] %}
.abnormal-row .ab-threshold, .abnormal-row .ab-module { display:none; } {% set _ = ns.abnormal.append((m.name, it)) %}
.item-row { grid-template-columns:1fr 1fr; gap:6px; } {% endif %}
.process-table { font-size:11px; } {% endfor %}
.process-table .cmd-cell { max-width:150px; } {% endfor %}
}
</style> {% if ns.abnormal %}
</head> <div class="section-title">⚠️ 异常项({{ ns.abnormal | length }})</div>
<body> <div class="abnormal-box">
<div class="navbar"> {% for mod_name, it in ns.abnormal %}
<div class="navbar-left"> <div class="abnormal-row">
<a href="/" class="navbar-brand">🛠️ 运行维护平台</a> <span class="ab-icon {{ 'ab-crit' if it.status == '严重' else 'ab-warn' }}">{{ '✗' if it.status == '严重' else '⚠' }}</span>
<span class="navbar-module">/ 服务监测 / 报告</span> <span><span class="ab-name">{{ it.name }}</span><br><span class="ab-module">{{ mod_name }}</span></span>
<a href="/service-monitor" class="navbar-home">← 返回</a> <span class="ab-value {{ 'ab-crit' if it.status == '严重' else 'ab-warn' }}">{{ it.value }}</span>
<span class="ab-threshold">{{ it.threshold or '-' }}</span>
<span class="item-status stt-{{ it.status }}">{{ it.status }}</span>
{% if is_admin %}
<button class="btn-fix" onclick="tryFix('{{ it.key }}')">修复</button>
{% else %}
<span></span>
{% endif %}
</div> </div>
<div class="navbar-user">{{ user.username }}({{ '管理员' if user.role == 'admin' else '用户' }})</div> {% endfor %}
</div> </div>
{% else %}
<div class="container"> <div class="section-title">✅ 全部正常</div>
<div class="report-head"> {% endif %}
<div class="report-meta">
<h2>{{ '🖥️' if report.target_type == 'local' else '🌐' }} {{ report.target_name }}</h2> <!-- 分模块折叠明细 -->
<div class="sub"> <div class="section-title">📋 检测明细(点击展开)</div>
{{ '⚡ 快速巡检' if report.suite == 'quick' else '🔍 全量巡检' }} · {% for m in report.modules %}
{{ (report.started_at or '').replace('T', ' ') }} ~ {{ (report.finished_at or '')[11:19] }} {% set ms = m.summary or {} %}
{% set has_abnormal = (ms.get('警告', 0) or ms.get('严重', 0)) %}
<div class="module-block{{ ' open' if has_abnormal else '' }}" data-module="{{ m.id }}">
<div class="module-header" onclick="toggleModule(this)">
<span class="module-toggle"></span>
<span class="module-title">{{ m.name }}</span>
<span class="module-mini">
{% if ms.get('正常') %}<span class="stt-正常">✓{{ ms['正常'] }}</span>{% endif %}
{% if ms.get('警告') %}<span class="stt-警告">⚠{{ ms['警告'] }}</span>{% endif %}
{% if ms.get('严重') %}<span class="stt-严重">✗{{ ms['严重'] }}</span>{% endif %}
</span>
</div>
<div class="module-body">
{% for it in m['items'] %}
{# 判断是否为进程列表项 #}
{% if it.key.endswith('_PROCESS_LIST') or it.key.endswith('_PROCESSES_DETAIL') %}
<div class="process-row">
<div class="process-summary">
<span>{{ it.name }}</span>
<button class="process-toggle-btn" onclick="toggleProcessTable(this)">展开详情</button>
</div> </div>
</div> <div class="process-table-wrap">
<div class="export-wrap"> <table class="process-table">
<button class="btn-export" onclick="toggleExport()">⬇ 导出 ▾</button> <thead><tr><th>用户</th><th>PID</th><th>CPU%</th><th>内存%</th><th>命令</th></tr></thead>
<div class="export-menu" id="export-menu"> <tbody>
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=md">导出 Markdown</a> {% for line in it.value.split('|') %}
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=json">导出 JSON</a> {% set parts = line.strip().split() %}
{% if parts|length >= 5 %}
<tr>
<td>{{ parts[0] }}</td>
<td>{{ parts[1] }}</td>
<td>{{ parts[2] }}</td>
<td>{{ parts[3] }}</td>
<td class="cmd-cell" title="{{ ' '.join(parts[4:]) }}">{{ ' '.join(parts[4:]) }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</div>
<!-- 汇总卡片 -->
{% set s = report.summary or {} %}
<div class="summary-cards">
<div class="sum-card sum-total"><div class="num">{{ s.total or 0 }}</div><div class="label">总检测项</div></div>
<div class="sum-card sum-ok"><div class="num">{{ s['正常'] or 0 }}</div><div class="label">正常</div></div>
<div class="sum-card sum-warn"><div class="num">{{ s['警告'] or 0 }}</div><div class="label">警告</div></div>
<div class="sum-card sum-crit"><div class="num">{{ s['严重'] or 0 }}</div><div class="label">严重</div></div>
</div>
<!-- 异常项置顶 -->
{% set ns = namespace(abnormal=[]) %}
{% for m in report.modules %}
{% for it in m['items'] %}
{% if it.status in ['警告', '严重'] %}
{% set _ = ns.abnormal.append((m.name, it)) %}
{% endif %}
{% endfor %}
{% endfor %}
{% if ns.abnormal %}
<div class="section-title">⚠️ 异常项({{ ns.abnormal | length }})</div>
<div class="abnormal-box">
{% for mod_name, it in ns.abnormal %}
<div class="abnormal-row">
<span class="ab-icon {{ 'ab-crit' if it.status == '严重' else 'ab-warn' }}">{{ '✗' if it.status == '严重' else '⚠' }}</span>
<span><span class="ab-name">{{ it.name }}</span><br><span class="ab-module">{{ mod_name }}</span></span>
<span class="ab-value {{ 'ab-crit' if it.status == '严重' else 'ab-warn' }}">{{ it.value }}</span>
<span class="ab-threshold">{{ it.threshold or '-' }}</span>
<span class="item-status stt-{{ it.status }}">{{ it.status }}</span>
{% if user.role == 'admin' %}
<button class="btn-fix" onclick="tryFix('{{ it.key }}')">修复</button>
{% else %} {% else %}
<span></span> <div class="item-row">
<span class="item-name">{{ it.name }}</span>
{% if it.value|length > 80 %}
<span class="item-value long-value" onclick="this.classList.toggle('expanded')">
<span class="truncated">{{ it.value[:80] }}…</span>
<span class="full">{{ it.value }}</span>
</span>
{% else %}
<span class="item-value">{{ it.value }}</span>
{% endif %} {% endif %}
<span class="item-threshold">{{ it.threshold or '-' }}</span>
<span class="item-status stt-{{ it.status }}">{{ it.status }}</span>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% else %}
<div class="section-title">✅ 全部正常</div>
{% endif %}
<!-- 分模块折叠明细 -->
<div class="section-title">📋 检测明细(点击展开)</div>
{% for m in report.modules %}
{% set ms = m.summary or {} %}
{% set has_abnormal = (ms.get('警告', 0) or ms.get('严重', 0)) %}
<div class="module-block{{ ' open' if has_abnormal else '' }}" data-module="{{ m.id }}">
<div class="module-header" onclick="toggleModule(this)">
<span class="module-toggle"></span>
<span class="module-title">{{ m.name }}</span>
<span class="module-mini">
{% if ms.get('正常') %}<span class="stt-正常">✓{{ ms['正常'] }}</span>{% endif %}
{% if ms.get('警告') %}<span class="stt-警告">⚠{{ ms['警告'] }}</span>{% endif %}
{% if ms.get('严重') %}<span class="stt-严重">✗{{ ms['严重'] }}</span>{% endif %}
</span>
</div>
<div class="module-body">
{% for it in m['items'] %}
{# 判断是否为进程列表项 #}
{% if it.key.endswith('_PROCESS_LIST') or it.key.endswith('_PROCESSES_DETAIL') %}
<div class="process-row">
<div class="process-summary">
<span>{{ it.name }}</span>
<button class="process-toggle-btn" onclick="toggleProcessTable(this)">展开详情</button>
</div>
<div class="process-table-wrap">
<table class="process-table">
<thead><tr><th>用户</th><th>PID</th><th>CPU%</th><th>内存%</th><th>命令</th></tr></thead>
<tbody>
{% for line in it.value.split('|') %}
{% set parts = line.strip().split() %}
{% if parts|length >= 5 %}
<tr>
<td>{{ parts[0] }}</td>
<td>{{ parts[1] }}</td>
<td>{{ parts[2] }}</td>
<td>{{ parts[3] }}</td>
<td class="cmd-cell" title="{{ ' '.join(parts[4:]) }}">{{ ' '.join(parts[4:]) }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="item-row">
<span class="item-name">{{ it.name }}</span>
{% if it.value|length > 80 %}
<span class="item-value long-value" onclick="this.classList.toggle('expanded')">
<span class="truncated">{{ it.value[:80] }}…</span>
<span class="full">{{ it.value }}</span>
</span>
{% else %}
<span class="item-value">{{ it.value }}</span>
{% endif %}
<span class="item-threshold">{{ it.threshold or '-' }}</span>
<span class="item-status stt-{{ it.status }}">{{ it.status }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div> </div>
{% endfor %}
<div class="footer">运行维护平台 · 服务监测模块</div> </div>
{% endblock %}
<script>
function toggleExport() { document.getElementById('export-menu').classList.toggle('show'); } {% block extra_js %}
document.addEventListener('click', (e) => { <script>
if (!e.target.closest('.export-wrap')) document.getElementById('export-menu').classList.remove('show'); function toggleExport() { document.getElementById('export-menu').classList.toggle('show'); }
}); document.addEventListener('click', (e) => {
if (!e.target.closest('.export-wrap')) document.getElementById('export-menu').classList.remove('show');
function toggleModule(header) { });
const block = header.parentElement;
block.classList.toggle('open'); function toggleModule(header) {
} const block = header.parentElement;
block.classList.toggle('open');
function toggleProcessTable(btn) { }
const wrap = btn.closest('.process-row').querySelector('.process-table-wrap');
wrap.classList.toggle('show'); function toggleProcessTable(btn) {
btn.textContent = wrap.classList.contains('show') ? '收起详情' : '展开详情'; const wrap = btn.closest('.process-row').querySelector('.process-table-wrap');
} wrap.classList.toggle('show');
btn.textContent = wrap.classList.contains('show') ? '收起详情' : '展开详情';
async function tryFix(key) { }
const r = await fetch('/api/service-monitor/fix', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify({key})});
const d = await r.json(); async function tryFix(key) {
alert(d.message || '修复能力开发中'); const r = await fetch('/api/service-monitor/fix', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify({key})});
} const d = await r.json();
</script> alert(d.message || '修复能力开发中');
</body> }
</html> </script>
{% endblock %}
\ No newline at end of file
{% extends "service_monitor/base.html" %}
{% block title %}巡检报告{% endblock %}
{% block extra_css %}
<style>
/* 筛选栏 */
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.filter-bar select {
padding: 8px 12px;
border: 1px solid var(--gray-200);
border-radius: 8px;
font-size: 14px;
color: var(--gray-700);
background: #fff;
}
.filter-bar select:focus {
outline: none;
border-color: var(--primary);
}
.report-count {
font-size: 13px;
color: var(--gray-500);
margin-left: auto;
}
/* 报告列表 */
.report-list {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,.06);
border: 1px solid var(--gray-200);
}
.report-row {
display: grid;
grid-template-columns: 2fr 1fr 2fr 2fr 1fr;
gap: 12px;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--gray-100);
text-decoration: none;
}
.report-row:last-child { border-bottom: none; }
.report-row:hover { background: var(--gray-50); }
.report-target { font-weight: 600; color: var(--gray-900); }
.report-suite { font-size: 13px; color: var(--gray-500); }
.report-summary { display: flex; gap: 10px; font-size: 13px; }
.sm-ok { color: var(--green); } .sm-warn { color: var(--yellow); } .sm-crit { color: var(--red); }
.report-time { font-size: 13px; color: var(--gray-500); }
.btn-view {
background: var(--gray-100);
color: var(--gray-700);
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
text-align: center;
}
.btn-view:hover { background: var(--gray-200); }
@media screen and (max-width: 768px) {
.report-row { grid-template-columns: 1fr 1fr; gap: 6px; }
.report-summary { font-size: 12px; }
.filter-bar { flex-wrap: wrap; }
}
</style>
{% endblock %}
{% block content %}
<div class="section-head">
<div class="section-title">📋 巡检报告</div>
</div>
<div class="filter-bar">
<select id="filter-target" onchange="filterByTarget()">
<option value="">全部目标</option>
{% for t in targets %}
<option value="{{ t.id }}" {{ 'selected' if request.args.get('target_id') == t.id }}>{{ t.name }}</option>
{% endfor %}
</select>
<span class="report-count">共 {{ reports | length }} 条报告</span>
</div>
{% if reports %}
<div class="report-list">
{% for r in reports %}
<a href="/service-monitor/report/{{ r.id }}" class="report-row">
<span class="report-target">{{ r.target_name }}</span>
<span class="report-suite">{{ '快速' if r.suite == 'quick' else '全量' }}</span>
<span class="report-summary">
<span class="sm-ok">✓{{ r.summary.正常 if r.summary else 0 }}</span>
<span class="sm-warn">⚠{{ r.summary.警告 if r.summary else 0 }}</span>
<span class="sm-crit">✗{{ r.summary.严重 if r.summary else 0 }}</span>
</span>
<span class="report-time">{{ (r.started_at or '')[5:16].replace('T', ' ') }}</span>
<span class="btn-view">查看</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty">📭 暂无巡检报告,点击目标卡片开始巡检</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
function filterByTarget() {
const targetId = document.getElementById('filter-target').value;
const url = targetId
? '/service-monitor/reports?target_id=' + encodeURIComponent(targetId)
: '/service-monitor/reports';
window.location.href = url;
}
</script>
{% endblock %}
\ No newline at end of file
<!DOCTYPE html> {% extends "service_monitor/base.html" %}
<html lang="zh-CN"> {% block title %}巡检执行{% endblock %}
<head> {% set no_footer = true %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5"> {% block extra_css %}
<title>巡检执行 - 服务监测</title> <style>
<style> .run-card { background: #fff; border-radius: 14px; box-shadow: 0 2px 8px rgba(0,0,0,.06); border: 1px solid var(--gray-200); padding: 26px; max-width: 720px; }
:root { .run-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
--primary:#2563eb; --primary-hover:#1d4ed8; .run-title { font-size: 18px; font-weight: 700; color: var(--gray-900); }
--green:#16a34a; --yellow:#d97706; --red:#dc2626; .run-title .suite-tag { font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 6px; margin-left: 8px; background: #eef2ff; color: var(--primary); }
--gray-50:#f9fafb; --gray-100:#f3f4f6; --gray-200:#e5e7eb; .btn-cancel { background: #fee2e2; color: var(--red); border: none; padding: 8px 16px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
--gray-400:#9ca3af; --gray-500:#6b7280; --gray-700:#374151; --gray-900:#111827; .btn-cancel:disabled { opacity: .4; cursor: not-allowed; }
}
* { box-sizing:border-box; margin:0; padding:0; } /* 进度条 */
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Microsoft YaHei",sans-serif; background:#f3f4f6; min-height:100vh; } .progress-wrap { margin-bottom: 6px; }
.navbar { background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); padding:14px 24px; display:flex; align-items:center; justify-content:space-between; } .progress-bar { height: 12px; background: var(--gray-100); border-radius: 6px; overflow: hidden; }
.navbar-left { display:flex; align-items:center; gap:14px; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--primary), #60a5fa); width: 0%; transition: width .3s; }
.navbar-brand { color:#fff; font-size:18px; font-weight:700; text-decoration:none; } .progress-text { display: flex; justify-content: space-between; font-size: 13px; color: var(--gray-500); margin-top: 8px; }
.navbar-module { color:rgba(255,255,255,.7); font-size:14px; }
.navbar-home { background:rgba(255,255,255,.2); color:#fff; border:1px solid rgba(255,255,255,.35); padding:6px 14px; border-radius:8px; font-size:13px; font-weight:600; text-decoration:none; } /* 模块清单 */
.navbar-user { color:#fff; font-size:14px; } .module-list { margin-top: 20px; }
.module-item { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--gray-100); }
.container { max-width:720px; margin:0 auto; padding:28px 24px; } .module-item:last-child { border-bottom: none; }
.run-card { background:#fff; border-radius:14px; box-shadow:0 2px 8px rgba(0,0,0,.06); border:1px solid var(--gray-200); padding:26px; } .module-status { font-size: 18px; width: 24px; text-align: center; }
.module-name { flex: 1; font-size: 15px; color: var(--gray-700); }
.run-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; } .module-info { font-size: 13px; color: var(--gray-500); }
.run-title { font-size:18px; font-weight:700; color:var(--gray-900); } .st-pending .module-name { color: var(--gray-400); }
.run-title .suite-tag { font-size:13px; font-weight:600; padding:3px 10px; border-radius:6px; margin-left:8px; background:#eef2ff; color:var(--primary); } .st-running .module-name { color: var(--primary); font-weight: 600; }
.btn-cancel { background:#fee2e2; color:var(--red); border:none; padding:8px 16px; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; } .st-done .module-name { color: var(--gray-900); }
.btn-cancel:disabled { opacity:.4; cursor:not-allowed; } .sm-ok { color: var(--green); } .sm-warn { color: var(--yellow); } .sm-crit { color: var(--red); }
/* 进度条 */ .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--gray-200); border-top-color: var(--primary); border-radius: 50%; animation: spin .8s linear infinite; }
.progress-wrap { margin-bottom:6px; } @keyframes spin { to { transform: rotate(360deg); } }
.progress-bar { height:12px; background:var(--gray-100); border-radius:6px; overflow:hidden; }
.progress-fill { height:100%; background:linear-gradient(90deg,var(--primary),#60a5fa); width:0%; transition:width .3s; } .done-banner { display: none; text-align: center; padding: 20px; margin-top: 10px; border-radius: 10px; }
.progress-text { display:flex; justify-content:space-between; font-size:13px; color:var(--gray-500); margin-top:8px; } .done-banner.show { display: block; }
.done-ok { background: #dcfce7; color: #15803d; }
/* 模块清单 */ .done-err { background: #fee2e2; color: var(--red); }
.module-list { margin-top:20px; } .btn-report { display: inline-block; margin-top: 12px; background: var(--primary); color: #fff; padding: 9px 22px; border-radius: 8px; font-size: 14px; font-weight: 600; text-decoration: none; }
.module-item { display:flex; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid var(--gray-100); } </style>
.module-item:last-child { border-bottom:none; } {% endblock %}
.module-status { font-size:18px; width:24px; text-align:center; }
.module-name { flex:1; font-size:15px; color:var(--gray-700); } {% block content %}
.module-info { font-size:13px; color:var(--gray-500); } <div class="run-card">
.st-pending .module-name { color:var(--gray-400); } <div class="run-head">
.st-running .module-name { color:var(--primary); font-weight:600; } <div class="run-title">
.st-done .module-name { color:var(--gray-900); } {{ '🖥️' if target.type == 'local' else '🌐' }} {{ target.name }}
.sm-ok { color:var(--green); } .sm-warn { color:var(--yellow); } .sm-crit { color:var(--red); } <span class="suite-tag">{{ '⚡ 快速巡检' if suite == 'quick' else '🔍 全量巡检' }}</span>
.spinner { display:inline-block; width:16px; height:16px; border:2px solid var(--gray-200); border-top-color:var(--primary); border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
.done-banner { display:none; text-align:center; padding:20px; margin-top:10px; border-radius:10px; }
.done-banner.show { display:block; }
.done-ok { background:#dcfce7; color:#15803d; }
.done-err { background:#fee2e2; color:var(--red); }
.btn-report { display:inline-block; margin-top:12px; background:var(--primary); color:#fff; padding:9px 22px; border-radius:8px; font-size:14px; font-weight:600; text-decoration:none; }
@media screen and (max-width:768px) {
.container { padding:20px 14px; }
.navbar-module { display:none; }
}
</style>
</head>
<body>
<div class="navbar">
<div class="navbar-left">
<a href="/" class="navbar-brand">🛠️ 运行维护平台</a>
<span class="navbar-module">/ 服务监测 / 巡检执行</span>
<a href="/service-monitor" class="navbar-home">← 返回</a>
</div> </div>
<div class="navbar-user">{{ user.username }}(管理员)</div> <button class="btn-cancel" id="btn-cancel" onclick="cancelRun()">取消</button>
</div> </div>
<div class="container"> <div class="progress-wrap">
<div class="run-card"> <div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<div class="run-head"> <div class="progress-text">
<div class="run-title"> <span id="progress-current">准备中...</span>
{{ '🖥️' if target.type == 'local' else '🌐' }} {{ target.name }} <span id="progress-count">0 / 0</span>
<span class="suite-tag">{{ '⚡ 快速巡检' if suite == 'quick' else '🔍 全量巡检' }}</span>
</div>
<button class="btn-cancel" id="btn-cancel" onclick="cancelRun()">取消</button>
</div>
<div class="progress-wrap">
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<div class="progress-text">
<span id="progress-current">准备中...</span>
<span id="progress-count">0 / 0</span>
</div>
</div>
<div class="module-list" id="module-list"></div>
<div class="done-banner" id="done-banner">
<div id="done-text"></div>
<a href="#" class="btn-report" id="btn-report" style="display:none;">查看报告 →</a>
</div>
</div> </div>
</div> </div>
<script> <div class="module-list" id="module-list"></div>
const TARGET_ID = "{{ target.id }}";
const SUITE = "{{ suite }}";
let currentRunId = null;
let evtSource = null;
const moduleEls = {};
function statusIcon(st) {
if (st === 'done') return '✓';
if (st === 'running') return '<span class="spinner"></span>';
return '○';
}
function ensureModuleEl(name) {
if (moduleEls[name]) return moduleEls[name];
const div = document.createElement('div');
div.className = 'module-item st-pending';
div.innerHTML = `<span class="module-status">○</span><span class="module-name">${name}</span><span class="module-info"></span>`;
document.getElementById('module-list').appendChild(div);
moduleEls[name] = div;
return div;
}
function setModuleStatus(name, st, info) {
const el = ensureModuleEl(name);
el.className = 'module-item st-' + st;
el.querySelector('.module-status').innerHTML = statusIcon(st);
if (info !== undefined) el.querySelector('.module-info').innerHTML = info;
}
function startInspection() { <div class="done-banner" id="done-banner">
const url = `/api/service-monitor/run/stream?target_id=${encodeURIComponent(TARGET_ID)}&suite=${encodeURIComponent(SUITE)}`; <div id="done-text"></div>
evtSource = new EventSource(url); <a href="#" class="btn-report" id="btn-report" style="display:none;">查看报告 →</a>
</div>
evtSource.onmessage = (e) => { </div>
let evt; {% endblock %}
try { evt = JSON.parse(e.data); } catch(_) { return; }
if (evt.run_id) currentRunId = evt.run_id; {% block extra_js %}
<script>
if (evt.event === 'progress') { const TARGET_ID = "{{ target.id }}";
document.getElementById('progress-current').textContent = evt.current || '巡检中...'; const SUITE = "{{ suite }}";
if (evt.total) { let currentRunId = null;
const pct = Math.round((evt.done / evt.total) * 100); let evtSource = null;
document.getElementById('progress-fill').style.width = pct + '%'; const moduleEls = {};
document.getElementById('progress-count').textContent = `${evt.done} / ${evt.total}`;
} function statusIcon(st) {
if (evt.current && !evt.current.includes('...') && !evt.current.includes('测试') && !evt.current.includes('上传')) { if (st === 'done') return '✓';
setModuleStatus(evt.current, 'running'); if (st === 'running') return '<span class="spinner"></span>';
} return '○';
} else if (evt.event === 'module_done') { }
const s = evt.summary || {};
let info = `${evt.items_count}项`; function ensureModuleEl(name) {
const parts = []; if (moduleEls[name]) return moduleEls[name];
if (s['正常']) parts.push(`<span class="sm-ok">✓${s['正常']}</span>`); const div = document.createElement('div');
if (s['警告']) parts.push(`<span class="sm-warn">⚠${s['警告']}</span>`); div.className = 'module-item st-pending';
if (s['严重']) parts.push(`<span class="sm-crit">✗${s['严重']}</span>`); div.innerHTML = `<span class="module-status">○</span><span class="module-name">${name}</span><span class="module-info"></span>`;
info += ' ' + parts.join(' '); document.getElementById('module-list').appendChild(div);
setModuleStatus(evt.module, 'done', info); moduleEls[name] = div;
} else if (evt.event === 'finished') { return div;
finishRun(true, evt.report_id, evt.summary); }
} else if (evt.event === 'error') {
finishRun(false, null, null, evt.message); function setModuleStatus(name, st, info) {
} else if (evt.event === 'cancelled') { const el = ensureModuleEl(name);
finishRun(false, null, null, '巡检已取消'); el.className = 'module-item st-' + st;
el.querySelector('.module-status').innerHTML = statusIcon(st);
if (info !== undefined) el.querySelector('.module-info').innerHTML = info;
}
function startInspection() {
const url = `/api/service-monitor/run/stream?target_id=${encodeURIComponent(TARGET_ID)}&suite=${encodeURIComponent(SUITE)}`;
evtSource = new EventSource(url);
evtSource.onmessage = (e) => {
let evt;
try { evt = JSON.parse(e.data); } catch(_) { return; }
if (evt.run_id) currentRunId = evt.run_id;
if (evt.event === 'progress') {
document.getElementById('progress-current').textContent = evt.current || '巡检中...';
if (evt.total) {
const pct = Math.round((evt.done / evt.total) * 100);
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-count').textContent = `${evt.done} / ${evt.total}`;
} }
}; if (evt.current && !evt.current.includes('...') && !evt.current.includes('测试') && !evt.current.includes('上传')) {
setModuleStatus(evt.current, 'running');
evtSource.onerror = () => { }
if (evtSource) { evtSource.close(); evtSource = null; } } else if (evt.event === 'module_done') {
}; const s = evt.summary || {};
} let info = `${evt.items_count}项`;
const parts = [];
if (s['正常']) parts.push(`<span class="sm-ok">✓${s['正常']}</span>`);
if (s['警告']) parts.push(`<span class="sm-warn">⚠${s['警告']}</span>`);
if (s['严重']) parts.push(`<span class="sm-crit">✗${s['严重']}</span>`);
info += ' ' + parts.join(' ');
setModuleStatus(evt.module, 'done', info);
} else if (evt.event === 'finished') {
finishRun(true, evt.report_id, evt.summary);
} else if (evt.event === 'error') {
finishRun(false, null, null, evt.message);
} else if (evt.event === 'cancelled') {
finishRun(false, null, null, '巡检已取消');
}
};
function finishRun(ok, reportId, summary, errMsg) { evtSource.onerror = () => {
if (evtSource) { evtSource.close(); evtSource = null; } if (evtSource) { evtSource.close(); evtSource = null; }
document.getElementById('btn-cancel').disabled = true; };
const banner = document.getElementById('done-banner'); }
const text = document.getElementById('done-text');
banner.classList.add('show'); function finishRun(ok, reportId, summary, errMsg) {
if (ok) { if (evtSource) { evtSource.close(); evtSource = null; }
document.getElementById('progress-fill').style.width = '100%'; document.getElementById('btn-cancel').disabled = true;
document.getElementById('progress-current').textContent = '巡检完成'; const banner = document.getElementById('done-banner');
banner.className = 'done-banner show done-ok'; const text = document.getElementById('done-text');
const s = summary || {}; banner.classList.add('show');
text.innerHTML = `✅ 巡检完成:正常 ${s['正常']||0},警告 ${s['警告']||0},严重 ${s['严重']||0}`; if (ok) {
if (reportId) { document.getElementById('progress-fill').style.width = '100%';
const btn = document.getElementById('btn-report'); document.getElementById('progress-current').textContent = '巡检完成';
btn.href = '/service-monitor/report/' + reportId; banner.className = 'done-banner show done-ok';
btn.style.display = 'inline-block'; const s = summary || {};
setTimeout(() => { window.location.href = btn.href; }, 1500); text.innerHTML = `✅ 巡检完成:正常 ${s['正常']||0},警告 ${s['警告']||0},严重 ${s['严重']||0}`;
} if (reportId) {
} else { const btn = document.getElementById('btn-report');
banner.className = 'done-banner show done-err'; btn.href = '/service-monitor/report/' + reportId;
text.textContent = '❌ ' + (errMsg || '巡检失败'); btn.style.display = 'inline-block';
setTimeout(() => { window.location.href = btn.href; }, 1500);
} }
} else {
banner.className = 'done-banner show done-err';
text.textContent = '❌ ' + (errMsg || '巡检失败');
} }
}
async function cancelRun() { async function cancelRun() {
if (currentRunId) { if (currentRunId) {
try { await fetch(`/api/service-monitor/run/${currentRunId}/cancel`, {method:'POST', credentials:'include'}); } catch(e) {} try { await fetch(`/api/service-monitor/run/${currentRunId}/cancel`, {method:'POST', credentials:'include'}); } catch(e) {}
}
if (evtSource) { evtSource.close(); evtSource = null; }
finishRun(false, null, null, '巡检已取消');
} }
if (evtSource) { evtSource.close(); evtSource = null; }
finishRun(false, null, null, '巡检已取消');
}
window.addEventListener('DOMContentLoaded', startInspection); window.addEventListener('DOMContentLoaded', startInspection);
</script> </script>
</body> {% endblock %}
</html> \ No newline at end of file
<!DOCTYPE html> {% extends "service_monitor/base.html" %}
<html lang="zh-CN"> {% block title %}目标管理{% endblock %}
<head>
<meta charset="UTF-8"> {% block extra_css %}
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5"> <style>
<title>目标管理 - 服务监测</title> /* 表格 */
<style> .table { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.06); border: 1px solid var(--gray-200); overflow: hidden; }
:root { .table-head, .table-row { display: grid; grid-template-columns: 1.5fr 2fr 1fr 1.5fr 1.5fr; gap: 12px; padding: 12px 18px; align-items: center; }
--primary: #2563eb; --primary-hover: #1d4ed8; .table-head { background: var(--gray-50); font-size: 13px; color: var(--gray-500); font-weight: 600; border-bottom: 1px solid var(--gray-200); }
--red: #dc2626; --green: #16a34a; .table-row { border-bottom: 1px solid var(--gray-100); font-size: 14px; }
--gray-50:#f9fafb; --gray-100:#f3f4f6; --gray-200:#e5e7eb; .table-row:last-child { border-bottom: none; }
--gray-500:#6b7280; --gray-700:#374151; --gray-900:#111827; .name { font-weight: 600; color: var(--gray-900); }
} .type-badge { font-size: 12px; padding: 2px 8px; border-radius: 4px; }
* { box-sizing: border-box; margin: 0; padding: 0; } .badge-local { background: #dcfce7; color: #15803d; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif; background:#f3f4f6; min-height:100vh; } .badge-remote { background: #dbeafe; color: #1d4ed8; }
.navbar { background: linear-gradient(135deg,#667eea 0%,#764ba2 100%); padding:14px 24px; display:flex; align-items:center; justify-content:space-between; } .actions { display: flex; gap: 8px; }
.navbar-left { display:flex; align-items:center; gap:14px; } .btn-sm { padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-block; }
.navbar-brand { color:#fff; font-size:18px; font-weight:700; text-decoration:none; } .btn-edit { background: var(--gray-100); color: var(--gray-700); }
.navbar-module { color:rgba(255,255,255,.7); font-size:14px; } .btn-del { background: #fee2e2; color: var(--red); }
.navbar-home { background:rgba(255,255,255,.2); color:#fff; border:1px solid rgba(255,255,255,.35); padding:6px 14px; border-radius:8px; font-size:13px; font-weight:600; text-decoration:none; } .btn-del:disabled { opacity: .4; cursor: not-allowed; }
.navbar-user { color:#fff; display:flex; align-items:center; gap:14px; font-size:14px; }
.btn-logout { background:rgba(255,255,255,.15); color:#fff; border:1px solid rgba(255,255,255,.3); padding:8px 16px; border-radius:8px; cursor:pointer; font-size:14px; } /* 弹窗 */
.modal-mask { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 200; align-items: center; justify-content: center; padding: 20px; }
.container { max-width:1000px; margin:0 auto; padding:28px 24px; } .modal-mask.show { display: flex; }
.section-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; } .modal { background: #fff; border-radius: 14px; padding: 28px; width: 100%; max-width: 460px; max-height: 90vh; overflow-y: auto; }
.section-title { font-size:18px; font-weight:700; color:var(--gray-900); } .modal h3 { font-size: 18px; color: var(--gray-900); margin-bottom: 18px; }
.btn-add { background:var(--primary); color:#fff; border:none; padding:8px 16px; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; } .form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: var(--gray-700); margin-bottom: 5px; font-weight: 600; }
/* 表格 */ .form-group input { width: 100%; padding: 9px 12px; border: 1px solid var(--gray-200); border-radius: 8px; font-size: 14px; }
.table { background:#fff; border-radius:12px; box-shadow:0 2px 8px rgba(0,0,0,.06); border:1px solid var(--gray-200); overflow:hidden; } .form-group input:focus { outline: none; border-color: var(--primary); }
.table-head, .table-row { display:grid; grid-template-columns: 1.5fr 2fr 1fr 1.5fr 1.5fr; gap:12px; padding:12px 18px; align-items:center; } .form-hint { font-size: 12px; color: var(--gray-500); margin-top: 4px; }
.table-head { background:var(--gray-50); font-size:13px; color:var(--gray-500); font-weight:600; border-bottom:1px solid var(--gray-200); } .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.table-row { border-bottom:1px solid var(--gray-100); font-size:14px; } .btn-cancel { background: var(--gray-100); color: var(--gray-700); border: none; padding: 9px 18px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; }
.table-row:last-child { border-bottom:none; } .btn-save { background: var(--primary); color: #fff; border: none; padding: 9px 18px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; }
.name { font-weight:600; color:var(--gray-900); } .btn-save:disabled { opacity: .6; cursor: not-allowed; }
.type-badge { font-size:12px; padding:2px 8px; border-radius:4px; } .btn-test { background: var(--gray-100); color: var(--gray-700); border: none; padding: 9px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; }
.badge-local { background:#dcfce7; color:#15803d; } .test-msg { font-size: 13px; margin-top: 8px; min-height: 18px; }
.badge-remote { background:#dbeafe; color:#1d4ed8; }
.actions { display:flex; gap:8px; } @media screen and (max-width: 768px) {
.btn-sm { padding:5px 12px; border-radius:6px; font-size:12px; font-weight:600; cursor:pointer; border:none; text-decoration:none; display:inline-block; } .table-head { display: none; }
.btn-edit { background:var(--gray-100); color:var(--gray-700); } .table-row { grid-template-columns: 1fr 1fr; gap: 6px; padding: 14px; }
.btn-del { background:#fee2e2; color:var(--red); } }
.btn-del:disabled { opacity:.4; cursor:not-allowed; } </style>
{% endblock %}
/* 弹窗 */
.modal-mask { display:none; position:fixed; inset:0; background:rgba(0,0,0,.4); z-index:100; align-items:center; justify-content:center; padding:20px; } {% block content %}
.modal-mask.show { display:flex; } <div class="section-head">
.modal { background:#fff; border-radius:14px; padding:28px; width:100%; max-width:460px; max-height:90vh; overflow-y:auto; } <div class="section-title">⚙️ 监测目标管理</div>
.modal h3 { font-size:18px; color:var(--gray-900); margin-bottom:18px; } <button class="btn-add" onclick="openCreate()">+ 新增远程目标</button>
.form-group { margin-bottom:14px; } </div>
.form-group label { display:block; font-size:13px; color:var(--gray-700); margin-bottom:5px; font-weight:600; }
.form-group input { width:100%; padding:9px 12px; border:1px solid var(--gray-200); border-radius:8px; font-size:14px; } <div class="table">
.form-group input:focus { outline:none; border-color:var(--primary); } <div class="table-head">
.form-hint { font-size:12px; color:var(--gray-500); margin-top:4px; } <span>名称</span><span>主机</span><span>类型</span><span>用户名</span><span>操作</span>
.modal-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; } </div>
.btn-cancel { background:var(--gray-100); color:var(--gray-700); border:none; padding:9px 18px; border-radius:8px; cursor:pointer; font-size:14px; font-weight:600; } {% for t in targets %}
.btn-save { background:var(--primary); color:#fff; border:none; padding:9px 18px; border-radius:8px; cursor:pointer; font-size:14px; font-weight:600; } <div class="table-row">
.btn-save:disabled { opacity:.6; cursor:not-allowed; } <span class="name">{{ t.name }}</span>
.btn-test { background:var(--gray-100); color:var(--gray-700); border:none; padding:9px 14px; border-radius:8px; cursor:pointer; font-size:13px; font-weight:600; } <span>{{ '本机' if t.type == 'local' else (t.host ~ ':' ~ t.port) }}</span>
.test-msg { font-size:13px; margin-top:8px; min-height:18px; } <span><span class="type-badge {{ 'badge-local' if t.type == 'local' else 'badge-remote' }}">{{ '本机' if t.type == 'local' else '远程' }}</span></span>
<span>{{ t.username or '-' }}</span>
.empty { text-align:center; padding:40px; color:var(--gray-500); } <div class="actions">
.footer { text-align:center; padding:20px; color:var(--gray-500); font-size:12px; } {% if t.type == 'remote' %}
<button class="btn-sm btn-edit" onclick="openEdit('{{ t.id }}')">编辑</button>
@media screen and (max-width:768px) { <button class="btn-sm btn-del" onclick="deleteTarget('{{ t.id }}')">删除</button>
.container { padding:20px 14px; } {% else %}
.table-head { display:none; } <button class="btn-sm btn-edit" disabled>内置</button>
.table-row { grid-template-columns: 1fr 1fr; gap:6px; padding:14px; } <button class="btn-sm btn-del" disabled>删除</button>
.navbar-module { display:none; } {% endif %}
}
</style>
</head>
<body>
<div class="navbar">
<div class="navbar-left">
<a href="/" class="navbar-brand">🛠️ 运行维护平台</a>
<span class="navbar-module">/ 服务监测 / 目标管理</span>
<a href="/service-monitor" class="navbar-home">← 返回</a>
</div>
<div class="navbar-user">
<span>{{ user.username }}(管理员)</span>
<button class="btn-logout" onclick="handleLogout()">🚪 退出</button>
</div> </div>
</div> </div>
{% endfor %}
<div class="container"> </div>
<div class="section-head">
<div class="section-title">⚙️ 监测目标管理</div> <!-- 新增/编辑弹窗 -->
<button class="btn-add" onclick="openCreate()">+ 新增远程目标</button> <div class="modal-mask" id="modal">
<div class="modal">
<h3 id="modal-title">新增远程目标</h3>
<div class="form-group">
<label>名称</label>
<input type="text" id="f-name" placeholder="如:生产服务器">
</div> </div>
<div class="form-group">
<div class="table"> <label>主机地址</label>
<div class="table-head"> <input type="text" id="f-host" placeholder="如:192.168.1.100">
<span>名称</span><span>主机</span><span>类型</span><span>用户名</span><span>操作</span>
</div>
{% for t in targets %}
<div class="table-row">
<span class="name">{{ t.name }}</span>
<span>{{ '本机' if t.type == 'local' else (t.host ~ ':' ~ t.port) }}</span>
<span><span class="type-badge {{ 'badge-local' if t.type == 'local' else 'badge-remote' }}">{{ '本机' if t.type == 'local' else '远程' }}</span></span>
<span>{{ t.username or '-' }}</span>
<div class="actions">
<button class="btn-sm btn-edit" onclick="openEdit('{{ t.id }}')">编辑</button>
<button class="btn-sm btn-del" onclick="delTarget('{{ t.id }}','{{ t.name }}')" {{ '' if not t.built_in else 'disabled' }}>删除</button>
</div>
</div>
{% endfor %}
</div> </div>
</div> <div class="form-group">
<label>SSH 端口</label>
<!-- 新增/编辑弹窗 --> <input type="number" id="f-port" value="22">
<div class="modal-mask" id="modal"> </div>
<div class="modal"> <div class="form-group">
<h3 id="modal-title">新增远程目标</h3> <label>用户名</label>
<input type="hidden" id="f-id"> <input type="text" id="f-username" placeholder="如:root">
<div class="form-group"> </div>
<label>目标名称</label> <div class="form-group">
<input type="text" id="f-name" placeholder="如:生产数据库服务器"> <label>密码</label>
</div> <input type="password" id="f-password" placeholder="SSH 登录密码">
<div class="form-group"> <div class="form-hint">密码将加密存储</div>
<label>主机地址</label> </div>
<input type="text" id="f-host" placeholder="192.168.x.x 或 域名"> <div style="margin-bottom: 14px;">
</div> <button class="btn-test" onclick="testConnection()">测试连接</button>
<div class="form-group">
<label>SSH 端口</label>
<input type="number" id="f-port" value="22">
</div>
<div class="form-group">
<label>用户名</label>
<input type="text" id="f-username" value="root">
</div>
<div class="form-group">
<label>密码 <span id="pw-label" style="font-weight:400;color:var(--gray-500)"></span></label>
<input type="password" id="f-password" placeholder="SSH 密码">
<div class="form-hint">密码将加密存储,编辑时不填表示不修改</div>
</div>
<div class="test-msg" id="test-msg"></div> <div class="test-msg" id="test-msg"></div>
<div class="modal-actions"> </div>
<button class="btn-test" onclick="testConn()">🔌 测试连接</button> <div class="modal-actions">
<button class="btn-cancel" onclick="closeModal()">取消</button> <button class="btn-cancel" onclick="closeModal()">取消</button>
<button class="btn-save" id="btn-save" onclick="saveTarget()">保存</button> <button class="btn-save" id="btn-save" onclick="saveTarget()">保存</button>
</div>
</div> </div>
</div> </div>
</div>
<div class="footer">运行维护平台 · 服务监测模块</div> {% endblock %}
<script> {% block extra_js %}
const TARGETS = {{ targets | tojson | safe }}; <script>
let editingId = null;
function openCreate() {
document.getElementById('modal-title').textContent = '新增远程目标'; function openCreate() {
document.getElementById('f-id').value = ''; editingId = null;
document.getElementById('f-name').value = ''; document.getElementById('modal-title').textContent = '新增远程目标';
document.getElementById('f-host').value = ''; document.getElementById('f-name').value = '';
document.getElementById('f-port').value = '22'; document.getElementById('f-host').value = '';
document.getElementById('f-username').value = 'root'; document.getElementById('f-port').value = '22';
document.getElementById('f-password').value = ''; document.getElementById('f-username').value = '';
document.getElementById('f-password').placeholder = 'SSH 密码'; document.getElementById('f-password').value = '';
document.getElementById('pw-label').textContent = ''; document.getElementById('test-msg').textContent = '';
document.getElementById('test-msg').textContent = ''; document.getElementById('modal').classList.add('show');
document.getElementById('modal').classList.add('show'); }
}
function openEdit(id) {
function openEdit(id) { editingId = id;
const t = TARGETS.find(x => x.id === id); document.getElementById('modal-title').textContent = '编辑目标';
if (!t) return; // TODO: 填充现有数据
document.getElementById('modal-title').textContent = '编辑目标:' + t.name; document.getElementById('modal').classList.add('show');
document.getElementById('f-id').value = t.id; }
document.getElementById('f-name').value = t.name;
document.getElementById('f-host').value = t.host || ''; function closeModal() {
document.getElementById('f-port').value = t.port || 22; document.getElementById('modal').classList.remove('show');
document.getElementById('f-username').value = t.username || ''; }
document.getElementById('f-password').value = '';
document.getElementById('f-password').placeholder = t.has_password ? '已设置,不填则不改' : 'SSH 密码'; async function testConnection() {
document.getElementById('pw-label').textContent = t.has_password ? '(已加密保存)' : ''; const msg = document.getElementById('test-msg');
document.getElementById('test-msg').textContent = ''; msg.textContent = '测试中...';
document.getElementById('modal').classList.add('show'); msg.style.color = 'var(--gray-500)';
}
const data = {
function closeModal() { document.getElementById('modal').classList.remove('show'); } host: document.getElementById('f-host').value,
port: parseInt(document.getElementById('f-port').value) || 22,
async function testConn() { username: document.getElementById('f-username').value,
const msg = document.getElementById('test-msg'); password: document.getElementById('f-password').value,
const host = document.getElementById('f-host').value.trim(); };
if (!host) { msg.style.color = 'var(--red)'; msg.textContent = '请先填写主机地址'; return; }
msg.style.color = 'var(--gray-500)'; msg.textContent = '测试中...'; if (!data.host || !data.username) {
try { msg.textContent = '请填写主机和用户名';
const r = await fetch('/api/service-monitor/targets/test', { msg.style.color = 'var(--red)';
method: 'POST', headers: {'Content-Type':'application/json'}, credentials:'include', return;
body: JSON.stringify({ }
host, port: parseInt(document.getElementById('f-port').value) || 22,
username: document.getElementById('f-username').value.trim(), try {
password: document.getElementById('f-password').value, const r = await fetch('/api/service-monitor/targets/test', {
}) method: 'POST',
}); headers: {'Content-Type': 'application/json'},
const d = await r.json(); credentials: 'include',
msg.style.color = d.success ? 'var(--green)' : 'var(--red)'; body: JSON.stringify(data),
if (d.success) { });
msg.textContent = '✓ ' + (d.message || '连接成功'); const d = await r.json();
msg.title = ''; if (d.success) {
} else { msg.textContent = '✓ 连接成功';
// 显示主消息,详细错误作为悬停提示 msg.style.color = 'var(--green)';
let text = '✗ ' + (d.message || '连接失败'); } else {
msg.textContent = text; msg.textContent = '✗ ' + (d.message || '连接失败');
msg.title = d.detail ? ('详情: ' + d.detail + (d.error_code ? ' [' + d.error_code + ']' : '')) : ''; msg.style.color = 'var(--red)';
} if (d.detail) msg.title = d.detail;
} catch(e) { msg.style.color='var(--red)'; msg.textContent = '✗ 请求失败'; }
} }
} catch (e) {
async function saveTarget() { msg.textContent = '✗ 请求失败';
const id = document.getElementById('f-id').value; msg.style.color = 'var(--red)';
const name = document.getElementById('f-name').value.trim(); }
const host = document.getElementById('f-host').value.trim(); }
const username = document.getElementById('f-username').value.trim();
const password = document.getElementById('f-password').value; async function saveTarget() {
if (!name || !host || !username) { alert('请填写完整信息'); return; } const data = {
if (!id && !password) { alert('请填写密码'); return; } name: document.getElementById('f-name').value,
host: document.getElementById('f-host').value,
const body = { name, host, port: parseInt(document.getElementById('f-port').value) || 22, username }; port: parseInt(document.getElementById('f-port').value) || 22,
if (password) body.password = password; username: document.getElementById('f-username').value,
password: document.getElementById('f-password').value,
const url = id ? '/api/service-monitor/targets/' + id : '/api/service-monitor/targets'; };
const method = id ? 'PUT' : 'POST';
try { if (!data.name || !data.host || !data.username) {
const r = await fetch(url, { method, headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }); alert('请填写名称、主机和用户名');
const d = await r.json(); return;
if (d.success) { location.reload(); } }
else { alert(d.error?.message || '保存失败'); }
} catch(e) { alert('请求失败'); } const url = editingId
? '/api/service-monitor/targets/' + editingId
: '/api/service-monitor/targets';
const method = editingId ? 'PUT' : 'POST';
try {
const r = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify(data),
});
const d = await r.json();
if (d.success) {
closeModal();
location.reload();
} else {
alert(d.error?.message || '保存失败');
} }
} catch (e) {
async function delTarget(id, name) { alert('请求失败');
if (!confirm('确认删除目标「' + name + '」?')) return; }
const r = await fetch('/api/service-monitor/targets/' + id, { method:'DELETE', credentials:'include' }); }
const d = await r.json();
if (d.success) location.reload(); else alert(d.error?.message || '删除失败'); async function deleteTarget(id) {
} if (!confirm('确定删除该目标?')) return;
try {
async function handleLogout() { const r = await fetch('/api/service-monitor/targets/' + id, {
try { await fetch('/logout', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include'}); } catch(e) {} method: 'DELETE',
window.location.href = '/login'; credentials: 'include',
});
const d = await r.json();
if (d.success) {
location.reload();
} else {
alert('删除失败');
} }
</script> } catch (e) {
</body> alert('请求失败');
</html> }
}
</script>
{% endblock %}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论