提交 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">
<title>服务监测 - 运行维护平台</title>
<style>
:root {
--primary: #2563eb;
--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%; }
/* 区块标题 */
.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); }
/* 目标卡片网格 */
.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 class="navbar-user">
<span>{{ user.username }}({{ '管理员' if is_admin else '用户' }})</span>
{% if is_admin %}<a href="/service-monitor/targets" class="navbar-home">⚙️ 目标管理</a>{% endif %}
<button class="btn-logout" onclick="handleLogout()">🚪 退出</button>
</div>
</div>
<div class="container">
<!-- 监测目标 -->
<div class="section-head">
<div class="section-title">📊 监测目标</div> <div class="section-title">📊 监测目标</div>
{% if is_admin %} {% if is_admin %}
<a href="/service-monitor/targets" class="btn-add">+ 目标管理</a> <a href="/service-monitor/targets" class="btn-add">+ 目标管理</a>
{% endif %} {% endif %}
</div> </div>
{% if targets %} {% if targets %}
<div class="target-grid"> <div class="target-grid">
{% for t in targets %} {% for t in targets %}
<div class="target-card"> <div class="target-card">
<div class="target-card-head"> <div class="target-card-head">
...@@ -163,44 +33,39 @@ ...@@ -163,44 +33,39 @@
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="empty">📭 暂无监测目标</div> <div class="empty">📭 暂无监测目标</div>
{% endif %} {% endif %}
{% endblock %}
<!-- 最近巡检报告 -->
<div class="section-head">
<div class="section-title">📋 最近巡检报告</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 %}
</div>
<div class="footer">运行维护平台 · 服务监测模块</div> {% block extra_css %}
<style>
<script> /* 目标卡片网格 */
async function handleLogout() { .target-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 36px; }
try { await fetch('/logout', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include'}); } catch(e) {} .target-card {
window.location.href = '/login'; 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;
} }
</script> .btn-quick { background: var(--gray-100); color: var(--gray-700); }
</body> .btn-quick:hover { background: var(--gray-200); }
</html> .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;
}
@media screen and (max-width: 1024px) { .target-grid { grid-template-columns: repeat(2, 1fr); } }
@media screen and (max-width: 768px) { .target-grid { grid-template-columns: 1fr; } }
</style>
{% endblock %}
\ 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">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5">
<title>巡检报告 - 服务监测</title>
<style>
:root {
--primary:#2563eb; --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; }
.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; }
.navbar-user { color:#fff; font-size:14px; }
.container { max-width:960px; margin:0 auto; padding:28px 24px; } {% block extra_css %}
<style>
.container { max-width: 960px; }
/* 报告头 */ /* 报告头 */
.report-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; flex-wrap:wrap; gap:12px; } .report-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; flex-wrap: wrap; gap: 12px; }
.report-meta h2 { font-size:20px; color:var(--gray-900); margin-bottom:4px; } .report-meta h2 { font-size: 20px; color: var(--gray-900); margin-bottom: 4px; }
.report-meta .sub { font-size:13px; color:var(--gray-500); } .report-meta .sub { font-size: 13px; color: var(--gray-500); }
.export-wrap { position:relative; } .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; } .btn-export { background: var(--primary); color: #fff; border: none; padding: 9px 18px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
.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; } .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; }
.export-menu.show { display:block; } .export-menu.show { display: block; }
.export-menu a { display:block; padding:10px 20px; font-size:14px; color:var(--gray-700); text-decoration:none; } .export-menu a { display: block; padding: 10px 20px; font-size: 14px; color: var(--gray-700); text-decoration: none; }
.export-menu a:hover { background:var(--gray-50); } .export-menu a:hover { background: var(--gray-50); }
/* 汇总卡片 */ /* 汇总卡片 */
.summary-cards { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:24px; } .summary-cards { display: grid; grid-template-columns: repeat(4,1fr); gap: 12px; margin-bottom: 24px; }
.sum-card { background:#fff; border-radius:12px; padding:18px; text-align:center; border:1px solid var(--gray-200); } .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; } .sum-card .num { font-size: 28px; font-weight: 700; }
.sum-card .label { font-size:13px; color:var(--gray-500); margin-top:4px; } .sum-card .label { font-size: 13px; color: var(--gray-500); margin-top: 4px; }
.sum-total .num { color:var(--gray-900); } .sum-total .num { color: var(--gray-900); }
.sum-ok .num { color:var(--green); } .sum-ok .num { color: var(--green); }
.sum-warn .num { color:var(--yellow); } .sum-warn .num { color: var(--yellow); }
.sum-crit .num { color:var(--red); } .sum-crit .num { color: var(--red); }
/* 异常项 */ /* 异常项 */
.section-title { font-size:16px; font-weight:700; color:var(--gray-900); margin:24px 0 12px; display:flex; align-items:center; gap:8px; } .section-title { font-size: 16px; font-weight: 700; color: var(--gray-900); margin: 24px 0 12px; display: flex; align-items: center; gap: 8px; }
.abnormal-box { background:#fff; border-radius:12px; border:1px solid var(--gray-200); overflow:hidden; } .abnormal-box { background: #fff; border-radius: 12px; border: 1px solid var(--gray-200); overflow: hidden; }
.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; } .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; }
.abnormal-row:last-child { border-bottom:none; } .abnormal-row:last-child { border-bottom: none; }
.ab-icon { text-align:center; } .ab-icon { text-align: center; }
.ab-crit { color:var(--red); } .ab-warn { color:var(--yellow); } .ab-crit { color: var(--red); } .ab-warn { color: var(--yellow); }
.ab-name { font-weight:600; color:var(--gray-900); } .ab-name { font-weight: 600; color: var(--gray-900); }
.ab-module { font-size:12px; color:var(--gray-500); } .ab-module { font-size: 12px; color: var(--gray-500); }
.ab-value { font-weight:600; } .ab-value { font-weight: 600; }
.ab-threshold { font-size:13px; color:var(--gray-500); } .ab-threshold { font-size: 13px; color: var(--gray-500); }
.btn-fix { background:#fef3c7; color:#92400e; border:none; padding:5px 12px; border-radius:6px; font-size:12px; font-weight:600; cursor:pointer; } .btn-fix { background: #fef3c7; color: #92400e; border: none; padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; }
/* 分模块折叠 */ /* 分模块折叠 */
.module-block { background:#fff; border-radius:12px; border:1px solid var(--gray-200); margin-bottom:12px; overflow:hidden; } .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 { display: flex; align-items: center; gap: 10px; padding: 14px 18px; cursor: pointer; user-select: none; }
.module-header:hover { background:var(--gray-50); } .module-header:hover { background: var(--gray-50); }
.module-toggle { font-size:12px; color:var(--gray-500); transition:transform .2s; flex-shrink:0; } .module-toggle { font-size: 12px; color: var(--gray-500); transition: transform .2s; flex-shrink: 0; }
.module-block.open .module-toggle { transform:rotate(90deg); } .module-block.open .module-toggle { transform: rotate(90deg); }
.module-title { font-weight:700; color:var(--gray-900); flex:1; } .module-title { font-weight: 700; color: var(--gray-900); flex: 1; }
.module-mini { display:flex; gap:8px; font-size:13px; } .module-mini { display: flex; gap: 8px; font-size: 13px; }
.module-body { max-height:0; overflow:hidden; transition:max-height .35s ease; } .module-body { max-height: 0; overflow: hidden; transition: max-height .35s ease; }
.module-block.open .module-body { max-height:5000px; } .module-block.open .module-body { max-height: 5000px; }
/* 检测项行 */ /* 检测项行 */
.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; } .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; }
.item-name { color:var(--gray-700); } .item-name { color: var(--gray-700); }
.item-value { color:var(--gray-900); font-weight:600; word-break:break-all; } .item-value { color: var(--gray-900); font-weight: 600; word-break: break-all; }
.item-threshold { color:var(--gray-500); font-size:13px; } .item-threshold { color: var(--gray-500); font-size: 13px; }
.item-status { font-weight:600; } .item-status { font-weight: 600; }
.stt-正常 { color:var(--green); } .stt-警告 { color:var(--yellow); } .stt-严重 { color:var(--red); } .stt-正常 { color: var(--green); } .stt-警告 { color: var(--yellow); } .stt-严重 { color: var(--red); }
/* 进程列表特殊样式 */ /* 进程列表特殊样式 */
.process-row { border-top:1px solid var(--gray-100); padding:10px 18px; } .process-row { border-top: 1px solid var(--gray-100); padding: 10px 18px; }
.process-summary { font-weight:600; color:var(--gray-900); margin-bottom:8px; display:flex; align-items:center; gap:8px; } .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); } .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); }
.process-toggle-btn:hover { background:var(--gray-200); } .process-toggle-btn:hover { background: var(--gray-200); }
.process-table-wrap { display:none; margin-top:8px; overflow-x:auto; } .process-table-wrap { display: none; margin-top: 8px; overflow-x: auto; }
.process-table-wrap.show { display:block; } .process-table-wrap.show { display: block; }
.process-table { width:100%; border-collapse:collapse; font-size:13px; } .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 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); } .process-table td { padding: 6px 10px; border-bottom: 1px solid var(--gray-100); color: var(--gray-900); }
.process-table tr:hover td { background:var(--gray-50); } .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; } .process-table .cmd-cell { font-family: monospace; font-size: 12px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 长值展开 */ /* 长值展开 */
.long-value { cursor:pointer; color:var(--primary); } .long-value { cursor: pointer; color: var(--primary); }
.long-value .truncated { display:inline; } .long-value .truncated { display: inline; }
.long-value .full { display:none; } .long-value .full { display: none; }
.long-value.expanded .truncated { display:none; } .long-value.expanded .truncated { display: none; }
.long-value.expanded .full { display:inline; white-space:pre-wrap; word-break:break-all; } .long-value.expanded .full { display: inline; white-space: pre-wrap; word-break: break-all; }
.footer { text-align:center; padding:20px; color:var(--gray-500); font-size:12px; } @media screen and (max-width: 768px) {
.summary-cards { grid-template-columns: repeat(2,1fr); }
@media screen and (max-width:768px) { .abnormal-row { grid-template-columns: 24px 1fr auto; gap: 6px; }
.container { padding:20px 14px; } .abnormal-row .ab-threshold, .abnormal-row .ab-module { display: none; }
.navbar-module { display:none; } .item-row { grid-template-columns: 1fr 1fr; gap: 6px; }
.summary-cards { grid-template-columns:repeat(2,1fr); } .process-table { font-size: 11px; }
.abnormal-row { grid-template-columns:24px 1fr auto; gap:6px; } .process-table .cmd-cell { max-width: 150px; }
.abnormal-row .ab-threshold, .abnormal-row .ab-module { display:none; }
.item-row { grid-template-columns:1fr 1fr; gap:6px; }
.process-table { font-size:11px; }
.process-table .cmd-cell { max-width:150px; }
} }
</style> </style>
</head> {% endblock %}
<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">{{ user.username }}({{ '管理员' if user.role == 'admin' else '用户' }})</div>
</div>
<div class="container"> {% block content %}
<div class="container">
<div class="report-head"> <div class="report-head">
<div class="report-meta"> <div class="report-meta">
<h2>{{ '🖥️' if report.target_type == 'local' else '🌐' }} {{ report.target_name }}</h2> <h2>{{ '🖥️' if report.target_type == 'local' else '🌐' }} {{ report.target_name }}</h2>
...@@ -166,7 +137,7 @@ ...@@ -166,7 +137,7 @@
<span class="ab-value {{ 'ab-crit' if it.status == '严重' else 'ab-warn' }}">{{ it.value }}</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="ab-threshold">{{ it.threshold or '-' }}</span>
<span class="item-status stt-{{ it.status }}">{{ it.status }}</span> <span class="item-status stt-{{ it.status }}">{{ it.status }}</span>
{% if user.role == 'admin' %} {% if is_admin %}
<button class="btn-fix" onclick="tryFix('{{ it.key }}')">修复</button> <button class="btn-fix" onclick="tryFix('{{ it.key }}')">修复</button>
{% else %} {% else %}
<span></span> <span></span>
...@@ -241,11 +212,11 @@ ...@@ -241,11 +212,11 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endblock %}
<div class="footer">运行维护平台 · 服务监测模块</div>
<script> {% block extra_js %}
<script>
function toggleExport() { document.getElementById('export-menu').classList.toggle('show'); } function toggleExport() { document.getElementById('export-menu').classList.toggle('show'); }
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('.export-wrap')) document.getElementById('export-menu').classList.remove('show'); if (!e.target.closest('.export-wrap')) document.getElementById('export-menu').classList.remove('show');
...@@ -267,6 +238,5 @@ ...@@ -267,6 +238,5 @@
const d = await r.json(); const d = await r.json();
alert(d.message || '修复能力开发中'); alert(d.message || '修复能力开发中');
} }
</script> </script>
</body> {% endblock %}
</html> \ 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; }
.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; }
.navbar-user { color:#fff; font-size:14px; }
.container { max-width:720px; margin:0 auto; padding:28px 24px; }
.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; }
.run-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
.run-title { font-size:18px; font-weight:700; color:var(--gray-900); }
.run-title .suite-tag { font-size:13px; font-weight:600; padding:3px 10px; border-radius:6px; margin-left:8px; background:#eef2ff; color:var(--primary); }
.btn-cancel { background:#fee2e2; color:var(--red); border:none; padding:8px 16px; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; }
.btn-cancel:disabled { opacity:.4; cursor:not-allowed; }
/* 进度条 */ /* 进度条 */
.progress-wrap { margin-bottom:6px; } .progress-wrap { margin-bottom: 6px; }
.progress-bar { height:12px; background:var(--gray-100); border-radius:6px; overflow:hidden; } .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; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--primary), #60a5fa); width: 0%; transition: width .3s; }
.progress-text { display:flex; justify-content:space-between; font-size:13px; color:var(--gray-500); margin-top:8px; } .progress-text { display: flex; justify-content: space-between; font-size: 13px; color: var(--gray-500); margin-top: 8px; }
/* 模块清单 */ /* 模块清单 */
.module-list { margin-top:20px; } .module-list { margin-top: 20px; }
.module-item { display:flex; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid var(--gray-100); } .module-item { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--gray-100); }
.module-item:last-child { border-bottom:none; } .module-item:last-child { border-bottom: none; }
.module-status { font-size:18px; width:24px; text-align:center; } .module-status { font-size: 18px; width: 24px; text-align: center; }
.module-name { flex:1; font-size:15px; color:var(--gray-700); } .module-name { flex: 1; font-size: 15px; color: var(--gray-700); }
.module-info { font-size:13px; color:var(--gray-500); } .module-info { font-size: 13px; color: var(--gray-500); }
.st-pending .module-name { color:var(--gray-400); } .st-pending .module-name { color: var(--gray-400); }
.st-running .module-name { color:var(--primary); font-weight:600; } .st-running .module-name { color: var(--primary); font-weight: 600; }
.st-done .module-name { color:var(--gray-900); } .st-done .module-name { color: var(--gray-900); }
.sm-ok { color:var(--green); } .sm-warn { color:var(--yellow); } .sm-crit { color:var(--red); } .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; } .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); } } @keyframes spin { to { transform: rotate(360deg); } }
.done-banner { display:none; text-align:center; padding:20px; margin-top:10px; border-radius:10px; } .done-banner { display: none; text-align: center; padding: 20px; margin-top: 10px; border-radius: 10px; }
.done-banner.show { display:block; } .done-banner.show { display: block; }
.done-ok { background:#dcfce7; color:#15803d; } .done-ok { background: #dcfce7; color: #15803d; }
.done-err { background:#fee2e2; color:var(--red); } .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; } .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; }
</style>
@media screen and (max-width:768px) { {% endblock %}
.container { padding:20px 14px; }
.navbar-module { display:none; } {% block content %}
} <div class="run-card">
</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">{{ user.username }}(管理员)</div>
</div>
<div class="container">
<div class="run-card">
<div class="run-head"> <div class="run-head">
<div class="run-title"> <div class="run-title">
{{ '🖥️' if target.type == 'local' else '🌐' }} {{ target.name }} {{ '🖥️' if target.type == 'local' else '🌐' }} {{ target.name }}
...@@ -96,10 +64,11 @@ ...@@ -96,10 +64,11 @@
<div id="done-text"></div> <div id="done-text"></div>
<a href="#" class="btn-report" id="btn-report" style="display:none;">查看报告 →</a> <a href="#" class="btn-report" id="btn-report" style="display:none;">查看报告 →</a>
</div> </div>
</div> </div>
</div> {% endblock %}
<script> {% block extra_js %}
<script>
const TARGET_ID = "{{ target.id }}"; const TARGET_ID = "{{ target.id }}";
const SUITE = "{{ suite }}"; const SUITE = "{{ suite }}";
let currentRunId = null; let currentRunId = null;
...@@ -204,6 +173,5 @@ ...@@ -204,6 +173,5 @@
} }
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">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5">
<title>目标管理 - 服务监测</title>
<style>
:root {
--primary: #2563eb; --primary-hover: #1d4ed8;
--red: #dc2626; --green: #16a34a;
--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; }
.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; }
.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; }
.container { max-width:1000px; margin:0 auto; padding:28px 24px; }
.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); }
.btn-add { background:var(--primary); color:#fff; border:none; padding:8px 16px; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; }
{% block extra_css %}
<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; } .table { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.06); border: 1px solid var(--gray-200); overflow: hidden; }
.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; } .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; }
.table-head { background:var(--gray-50); font-size:13px; color:var(--gray-500); font-weight:600; border-bottom:1px solid var(--gray-200); } .table-head { background: var(--gray-50); font-size: 13px; color: var(--gray-500); font-weight: 600; border-bottom: 1px solid var(--gray-200); }
.table-row { border-bottom:1px solid var(--gray-100); font-size:14px; } .table-row { border-bottom: 1px solid var(--gray-100); font-size: 14px; }
.table-row:last-child { border-bottom:none; } .table-row:last-child { border-bottom: none; }
.name { font-weight:600; color:var(--gray-900); } .name { font-weight: 600; color: var(--gray-900); }
.type-badge { font-size:12px; padding:2px 8px; border-radius:4px; } .type-badge { font-size: 12px; padding: 2px 8px; border-radius: 4px; }
.badge-local { background:#dcfce7; color:#15803d; } .badge-local { background: #dcfce7; color: #15803d; }
.badge-remote { background:#dbeafe; color:#1d4ed8; } .badge-remote { background: #dbeafe; color: #1d4ed8; }
.actions { display:flex; gap:8px; } .actions { display: flex; gap: 8px; }
.btn-sm { padding:5px 12px; border-radius:6px; font-size:12px; font-weight:600; cursor:pointer; border:none; text-decoration:none; display:inline-block; } .btn-sm { padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; border: none; text-decoration: none; display: inline-block; }
.btn-edit { background:var(--gray-100); color:var(--gray-700); } .btn-edit { background: var(--gray-100); color: var(--gray-700); }
.btn-del { background:#fee2e2; color:var(--red); } .btn-del { background: #fee2e2; color: var(--red); }
.btn-del:disabled { opacity:.4; cursor:not-allowed; } .btn-del:disabled { opacity: .4; cursor: not-allowed; }
/* 弹窗 */ /* 弹窗 */
.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; } .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; }
.modal-mask.show { display:flex; } .modal-mask.show { display: flex; }
.modal { background:#fff; border-radius:14px; padding:28px; width:100%; max-width:460px; max-height:90vh; overflow-y:auto; } .modal { background: #fff; border-radius: 14px; padding: 28px; width: 100%; max-width: 460px; max-height: 90vh; overflow-y: auto; }
.modal h3 { font-size:18px; color:var(--gray-900); margin-bottom:18px; } .modal h3 { font-size: 18px; color: var(--gray-900); margin-bottom: 18px; }
.form-group { margin-bottom:14px; } .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 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; } .form-group input { width: 100%; padding: 9px 12px; border: 1px solid var(--gray-200); border-radius: 8px; font-size: 14px; }
.form-group input:focus { outline:none; border-color:var(--primary); } .form-group input:focus { outline: none; border-color: var(--primary); }
.form-hint { font-size:12px; color:var(--gray-500); margin-top:4px; } .form-hint { font-size: 12px; color: var(--gray-500); margin-top: 4px; }
.modal-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; } .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.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; } .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; }
.btn-save { background:var(--primary); color:#fff; border:none; padding:9px 18px; border-radius:8px; cursor:pointer; font-size:14px; font-weight:600; } .btn-save { background: var(--primary); color: #fff; border: none; padding: 9px 18px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; }
.btn-save:disabled { opacity:.6; cursor:not-allowed; } .btn-save:disabled { opacity: .6; cursor: not-allowed; }
.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; } .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; }
.test-msg { font-size:13px; margin-top:8px; min-height:18px; } .test-msg { font-size: 13px; margin-top: 8px; min-height: 18px; }
.empty { text-align:center; padding:40px; color:var(--gray-500); } @media screen and (max-width: 768px) {
.footer { text-align:center; padding:20px; color:var(--gray-500); font-size:12px; } .table-head { display: none; }
.table-row { grid-template-columns: 1fr 1fr; gap: 6px; padding: 14px; }
@media screen and (max-width:768px) {
.container { padding:20px 14px; }
.table-head { display:none; }
.table-row { grid-template-columns: 1fr 1fr; gap:6px; padding:14px; }
.navbar-module { display:none; }
} }
</style> </style>
</head> {% endblock %}
<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 class="container"> {% block content %}
<div class="section-head"> <div class="section-head">
<div class="section-title">⚙️ 监测目标管理</div> <div class="section-title">⚙️ 监测目标管理</div>
<button class="btn-add" onclick="openCreate()">+ 新增远程目标</button> <button class="btn-add" onclick="openCreate()">+ 新增远程目标</button>
</div> </div>
<div class="table"> <div class="table">
<div class="table-head"> <div class="table-head">
<span>名称</span><span>主机</span><span>类型</span><span>用户名</span><span>操作</span> <span>名称</span><span>主机</span><span>类型</span><span>用户名</span><span>操作</span>
</div> </div>
...@@ -100,26 +60,29 @@ ...@@ -100,26 +60,29 @@
<span><span class="type-badge {{ 'badge-local' if t.type == 'local' else 'badge-remote' }}">{{ '本机' if t.type == 'local' else '远程' }}</span></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> <span>{{ t.username or '-' }}</span>
<div class="actions"> <div class="actions">
{% if t.type == 'remote' %}
<button class="btn-sm btn-edit" onclick="openEdit('{{ t.id }}')">编辑</button> <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> <button class="btn-sm btn-del" onclick="deleteTarget('{{ t.id }}')">删除</button>
{% else %}
<button class="btn-sm btn-edit" disabled>内置</button>
<button class="btn-sm btn-del" disabled>删除</button>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
<!-- 新增/编辑弹窗 --> <!-- 新增/编辑弹窗 -->
<div class="modal-mask" id="modal"> <div class="modal-mask" id="modal">
<div class="modal"> <div class="modal">
<h3 id="modal-title">新增远程目标</h3> <h3 id="modal-title">新增远程目标</h3>
<input type="hidden" id="f-id">
<div class="form-group"> <div class="form-group">
<label>目标名称</label> <label>名称</label>
<input type="text" id="f-name" placeholder="如:生产数据库服务器"> <input type="text" id="f-name" placeholder="如:生产服务器">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>主机地址</label> <label>主机地址</label>
<input type="text" id="f-host" placeholder="192.168.x.x 或 域名"> <input type="text" id="f-host" placeholder="如:192.168.1.100">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>SSH 端口</label> <label>SSH 端口</label>
...@@ -127,120 +90,146 @@ ...@@ -127,120 +90,146 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>用户名</label> <label>用户名</label>
<input type="text" id="f-username" value="root"> <input type="text" id="f-username" placeholder="如:root">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>密码 <span id="pw-label" style="font-weight:400;color:var(--gray-500)"></span></label> <label>密码</label>
<input type="password" id="f-password" placeholder="SSH 密码"> <input type="password" id="f-password" placeholder="SSH 登录密码">
<div class="form-hint">密码将加密存储,编辑时不填表示不修改</div> <div class="form-hint">密码将加密存储</div>
</div> </div>
<div style="margin-bottom: 14px;">
<button class="btn-test" onclick="testConnection()">测试连接</button>
<div class="test-msg" id="test-msg"></div> <div class="test-msg" id="test-msg"></div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-test" onclick="testConn()">🔌 测试连接</button>
<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>
{% endblock %}
<div class="footer">运行维护平台 · 服务监测模块</div>
<script> {% block extra_js %}
const TARGETS = {{ targets | tojson | safe }}; <script>
let editingId = null;
function openCreate() { function openCreate() {
editingId = null;
document.getElementById('modal-title').textContent = '新增远程目标'; document.getElementById('modal-title').textContent = '新增远程目标';
document.getElementById('f-id').value = '';
document.getElementById('f-name').value = ''; document.getElementById('f-name').value = '';
document.getElementById('f-host').value = ''; document.getElementById('f-host').value = '';
document.getElementById('f-port').value = '22'; document.getElementById('f-port').value = '22';
document.getElementById('f-username').value = 'root'; document.getElementById('f-username').value = '';
document.getElementById('f-password').value = ''; document.getElementById('f-password').value = '';
document.getElementById('f-password').placeholder = 'SSH 密码';
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) {
const t = TARGETS.find(x => x.id === id); editingId = id;
if (!t) return; document.getElementById('modal-title').textContent = '编辑目标';
document.getElementById('modal-title').textContent = '编辑目标:' + t.name; // TODO: 填充现有数据
document.getElementById('f-id').value = t.id;
document.getElementById('f-name').value = t.name;
document.getElementById('f-host').value = t.host || '';
document.getElementById('f-port').value = t.port || 22;
document.getElementById('f-username').value = t.username || '';
document.getElementById('f-password').value = '';
document.getElementById('f-password').placeholder = t.has_password ? '已设置,不填则不改' : 'SSH 密码';
document.getElementById('pw-label').textContent = t.has_password ? '(已加密保存)' : '';
document.getElementById('test-msg').textContent = '';
document.getElementById('modal').classList.add('show'); document.getElementById('modal').classList.add('show');
} }
function closeModal() { document.getElementById('modal').classList.remove('show'); } function closeModal() {
document.getElementById('modal').classList.remove('show');
}
async function testConn() { async function testConnection() {
const msg = document.getElementById('test-msg'); const msg = document.getElementById('test-msg');
const host = document.getElementById('f-host').value.trim(); msg.textContent = '测试中...';
if (!host) { msg.style.color = 'var(--red)'; msg.textContent = '请先填写主机地址'; return; } msg.style.color = 'var(--gray-500)';
msg.style.color = 'var(--gray-500)'; msg.textContent = '测试中...';
const data = {
host: document.getElementById('f-host').value,
port: parseInt(document.getElementById('f-port').value) || 22,
username: document.getElementById('f-username').value,
password: document.getElementById('f-password').value,
};
if (!data.host || !data.username) {
msg.textContent = '请填写主机和用户名';
msg.style.color = 'var(--red)';
return;
}
try { try {
const r = await fetch('/api/service-monitor/targets/test', { const r = await fetch('/api/service-monitor/targets/test', {
method: 'POST', headers: {'Content-Type':'application/json'}, credentials:'include', method: 'POST',
body: JSON.stringify({ headers: {'Content-Type': 'application/json'},
host, port: parseInt(document.getElementById('f-port').value) || 22, credentials: 'include',
username: document.getElementById('f-username').value.trim(), body: JSON.stringify(data),
password: document.getElementById('f-password').value,
})
}); });
const d = await r.json(); const d = await r.json();
msg.style.color = d.success ? 'var(--green)' : 'var(--red)';
if (d.success) { if (d.success) {
msg.textContent = '✓ ' + (d.message || '连接成功'); msg.textContent = '✓ 连接成功';
msg.title = ''; msg.style.color = 'var(--green)';
} else { } else {
// 显示主消息,详细错误作为悬停提示 msg.textContent = '✗ ' + (d.message || '连接失败');
let text = '✗ ' + (d.message || '连接失败'); msg.style.color = 'var(--red)';
msg.textContent = text; if (d.detail) msg.title = d.detail;
msg.title = d.detail ? ('详情: ' + d.detail + (d.error_code ? ' [' + d.error_code + ']' : '')) : '';
} }
} catch(e) { msg.style.color='var(--red)'; msg.textContent = '✗ 请求失败'; } } catch (e) {
msg.textContent = '✗ 请求失败';
msg.style.color = 'var(--red)';
} }
}
async function saveTarget() {
const data = {
name: document.getElementById('f-name').value,
host: document.getElementById('f-host').value,
port: parseInt(document.getElementById('f-port').value) || 22,
username: document.getElementById('f-username').value,
password: document.getElementById('f-password').value,
};
async function saveTarget() { if (!data.name || !data.host || !data.username) {
const id = document.getElementById('f-id').value; alert('请填写名称、主机和用户名');
const name = document.getElementById('f-name').value.trim(); return;
const host = document.getElementById('f-host').value.trim(); }
const username = document.getElementById('f-username').value.trim();
const password = document.getElementById('f-password').value;
if (!name || !host || !username) { alert('请填写完整信息'); return; }
if (!id && !password) { alert('请填写密码'); return; }
const body = { name, host, port: parseInt(document.getElementById('f-port').value) || 22, username }; const url = editingId
if (password) body.password = password; ? '/api/service-monitor/targets/' + editingId
: '/api/service-monitor/targets';
const method = editingId ? 'PUT' : 'POST';
const url = id ? '/api/service-monitor/targets/' + id : '/api/service-monitor/targets';
const method = id ? 'PUT' : 'POST';
try { try {
const r = await fetch(url, { method, headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }); const r = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify(data),
});
const d = await r.json(); const d = await r.json();
if (d.success) { location.reload(); } if (d.success) {
else { alert(d.error?.message || '保存失败'); } closeModal();
} catch(e) { alert('请求失败'); } location.reload();
} else {
alert(d.error?.message || '保存失败');
}
} catch (e) {
alert('请求失败');
} }
}
async function delTarget(id, name) { async function deleteTarget(id) {
if (!confirm('确认删除目标「' + name + '」?')) return; if (!confirm('确定删除该目标?')) return;
const r = await fetch('/api/service-monitor/targets/' + id, { method:'DELETE', credentials:'include' }); try {
const r = await fetch('/api/service-monitor/targets/' + id, {
method: 'DELETE',
credentials: 'include',
});
const d = await r.json(); const d = await r.json();
if (d.success) location.reload(); else alert(d.error?.message || '删除失败'); if (d.success) {
location.reload();
} else {
alert('删除失败');
} }
} catch (e) {
async function handleLogout() { alert('请求失败');
try { await fetch('/logout', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include'}); } catch(e) {}
window.location.href = '/login';
} }
</script> }
</body> </script>
</html> {% endblock %}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论