提交 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 (
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")
......@@ -61,16 +61,16 @@ def _require_admin_json():
@bp.route('/service-monitor')
def page_index():
"""监测主页:目标概览 + 最近报告。"""
"""监测主页:目标概览。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
targets = target_service.list_targets(role=user.get('role', ''))
reports = report_service.list_reports(limit=20)
return render_template(
'service_monitor/index.html',
user=user, targets=targets, reports=reports,
user=user, targets=targets,
is_admin=(user.get('role') == 'admin'),
active_menu='targets',
)
......@@ -83,7 +83,43 @@ def page_targets():
if user.get('role') != 'admin':
return redirect(url_for('service-monitor.page_index'))
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>')
......@@ -99,7 +135,8 @@ def page_run(target_id):
return redirect(url_for('service-monitor.page_index'))
suite = request.args.get('suite', 'quick')
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>')
......@@ -111,7 +148,11 @@ def page_report(report_id):
report = report_service.get_report(report_id)
if not report:
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():
if guard:
return guard
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
{% 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
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论