Committed by
GitHub
Shutdown only clean 增加安全关闭系统的功能 (#441)
* feat: add shutdown endpoint and minimal UI * feat(ui): 添加系统关闭确认弹窗和安全刷新功能 - 新增系统关闭确认弹窗,显示运行中的服务和端口状态 - 添加安全刷新按钮,优化状态更新流程 - 重构关机逻辑,增加超时处理和错误恢复机制 - 改进状态栏布局和操作按钮样式 * feat(ui): update shutdown messages in index.html
Showing
3 changed files
with
603 additions
and
8 deletions
| @@ -343,4 +343,6 @@ OperationGuidance/ | @@ -343,4 +343,6 @@ OperationGuidance/ | ||
| 343 | insight_engine_streamlit_reports/ | 343 | insight_engine_streamlit_reports/ |
| 344 | media_engine_streamlit_reports/ | 344 | media_engine_streamlit_reports/ |
| 345 | query_engine_streamlit_reports/ | 345 | query_engine_streamlit_reports/ |
| 346 | -final_reports/ | ||
| 346 | +final_reports/ | ||
| 347 | +forward_prs.sh | ||
| 348 | +变更报告/屏幕录制 2025-12-03 094707.mp4 |
| @@ -226,7 +226,8 @@ def write_config_values(updates): | @@ -226,7 +226,8 @@ def write_config_values(updates): | ||
| 226 | system_state_lock = threading.Lock() | 226 | system_state_lock = threading.Lock() |
| 227 | system_state = { | 227 | system_state = { |
| 228 | 'started': False, | 228 | 'started': False, |
| 229 | - 'starting': False | 229 | + 'starting': False, |
| 230 | + 'shutdown_in_progress': False | ||
| 230 | } | 231 | } |
| 231 | 232 | ||
| 232 | 233 | ||
| @@ -255,6 +256,14 @@ def _prepare_system_start(): | @@ -255,6 +256,14 @@ def _prepare_system_start(): | ||
| 255 | system_state['starting'] = True | 256 | system_state['starting'] = True |
| 256 | return True, None | 257 | return True, None |
| 257 | 258 | ||
| 259 | +def _mark_shutdown_requested(): | ||
| 260 | + """标记关机已请求;若已有关机流程则返回 False。""" | ||
| 261 | + with system_state_lock: | ||
| 262 | + if system_state.get('shutdown_in_progress'): | ||
| 263 | + return False | ||
| 264 | + system_state['shutdown_in_progress'] = True | ||
| 265 | + return True | ||
| 266 | + | ||
| 258 | 267 | ||
| 259 | def initialize_system_components(): | 268 | def initialize_system_components(): |
| 260 | """启动所有依赖组件(Streamlit 子应用、ForumEngine、ReportEngine)。""" | 269 | """启动所有依赖组件(Streamlit 子应用、ForumEngine、ReportEngine)。""" |
| @@ -500,6 +509,21 @@ STREAMLIT_SCRIPTS = { | @@ -500,6 +509,21 @@ STREAMLIT_SCRIPTS = { | ||
| 500 | 'query': 'SingleEngineApp/query_engine_streamlit_app.py' | 509 | 'query': 'SingleEngineApp/query_engine_streamlit_app.py' |
| 501 | } | 510 | } |
| 502 | 511 | ||
| 512 | +def _log_shutdown_step(message: str): | ||
| 513 | + """统一记录关机步骤,便于排查。""" | ||
| 514 | + logger.info(f"[Shutdown] {message}") | ||
| 515 | + | ||
| 516 | + | ||
| 517 | +def _describe_running_children(): | ||
| 518 | + """列出当前存活的子进程。""" | ||
| 519 | + running = [] | ||
| 520 | + for name, info in processes.items(): | ||
| 521 | + proc = info.get('process') | ||
| 522 | + if proc is not None and proc.poll() is None: | ||
| 523 | + port_desc = f", port={info.get('port')}" if info.get('port') else "" | ||
| 524 | + running.append(f"{name}(pid={proc.pid}{port_desc})") | ||
| 525 | + return running | ||
| 526 | + | ||
| 503 | # 输出队列 | 527 | # 输出队列 |
| 504 | output_queues = { | 528 | output_queues = { |
| 505 | 'insight': Queue(), | 529 | 'insight': Queue(), |
| @@ -683,18 +707,28 @@ def start_streamlit_app(app_name, script_path, port): | @@ -683,18 +707,28 @@ def start_streamlit_app(app_name, script_path, port): | ||
| 683 | def stop_streamlit_app(app_name): | 707 | def stop_streamlit_app(app_name): |
| 684 | """停止Streamlit应用""" | 708 | """停止Streamlit应用""" |
| 685 | try: | 709 | try: |
| 686 | - if processes[app_name]['process'] is None: | 710 | + process = processes[app_name]['process'] |
| 711 | + if process is None: | ||
| 712 | + _log_shutdown_step(f"{app_name} 未运行,跳过停止") | ||
| 687 | return False, "应用未运行" | 713 | return False, "应用未运行" |
| 688 | 714 | ||
| 689 | - process = processes[app_name]['process'] | 715 | + try: |
| 716 | + pid = process.pid | ||
| 717 | + except Exception: | ||
| 718 | + pid = 'unknown' | ||
| 719 | + | ||
| 720 | + _log_shutdown_step(f"正在停止 {app_name} (pid={pid})") | ||
| 690 | process.terminate() | 721 | process.terminate() |
| 691 | 722 | ||
| 692 | # 等待进程结束 | 723 | # 等待进程结束 |
| 693 | try: | 724 | try: |
| 694 | process.wait(timeout=5) | 725 | process.wait(timeout=5) |
| 726 | + _log_shutdown_step(f"{app_name} 退出完成,returncode={process.returncode}") | ||
| 695 | except subprocess.TimeoutExpired: | 727 | except subprocess.TimeoutExpired: |
| 728 | + _log_shutdown_step(f"{app_name} 终止超时,尝试强制结束 (pid={pid})") | ||
| 696 | process.kill() | 729 | process.kill() |
| 697 | process.wait() | 730 | process.wait() |
| 731 | + _log_shutdown_step(f"{app_name} 已强制结束,returncode={process.returncode}") | ||
| 698 | 732 | ||
| 699 | processes[app_name]['process'] = None | 733 | processes[app_name]['process'] = None |
| 700 | processes[app_name]['status'] = 'stopped' | 734 | processes[app_name]['status'] = 'stopped' |
| @@ -702,6 +736,7 @@ def stop_streamlit_app(app_name): | @@ -702,6 +736,7 @@ def stop_streamlit_app(app_name): | ||
| 702 | return True, f"{app_name} 应用已停止" | 736 | return True, f"{app_name} 应用已停止" |
| 703 | 737 | ||
| 704 | except Exception as e: | 738 | except Exception as e: |
| 739 | + _log_shutdown_step(f"{app_name} 停止失败: {e}") | ||
| 705 | return False, f"停止失败: {str(e)}" | 740 | return False, f"停止失败: {str(e)}" |
| 706 | 741 | ||
| 707 | HEALTHCHECK_PATH = "/_stcore/health" | 742 | HEALTHCHECK_PATH = "/_stcore/health" |
| @@ -766,6 +801,7 @@ def wait_for_app_startup(app_name, max_wait_time=90): | @@ -766,6 +801,7 @@ def wait_for_app_startup(app_name, max_wait_time=90): | ||
| 766 | 801 | ||
| 767 | def cleanup_processes(): | 802 | def cleanup_processes(): |
| 768 | """清理所有进程""" | 803 | """清理所有进程""" |
| 804 | + _log_shutdown_step("开始串行清理子进程") | ||
| 769 | for app_name in STREAMLIT_SCRIPTS: | 805 | for app_name in STREAMLIT_SCRIPTS: |
| 770 | stop_streamlit_app(app_name) | 806 | stop_streamlit_app(app_name) |
| 771 | 807 | ||
| @@ -774,8 +810,101 @@ def cleanup_processes(): | @@ -774,8 +810,101 @@ def cleanup_processes(): | ||
| 774 | stop_forum_engine() | 810 | stop_forum_engine() |
| 775 | except Exception: # pragma: no cover | 811 | except Exception: # pragma: no cover |
| 776 | logger.exception("停止ForumEngine失败") | 812 | logger.exception("停止ForumEngine失败") |
| 813 | + _log_shutdown_step("子进程清理完成") | ||
| 777 | _set_system_state(started=False, starting=False) | 814 | _set_system_state(started=False, starting=False) |
| 778 | 815 | ||
| 816 | +def cleanup_processes_concurrent(timeout: float = 6.0): | ||
| 817 | + """并发清理所有子进程,超时后强制杀掉残留进程。""" | ||
| 818 | + _log_shutdown_step(f"开始并发清理子进程(超时 {timeout}s)") | ||
| 819 | + _log_shutdown_step("仅终止当前控制台启动并记录的子进程,不做端口扫描") | ||
| 820 | + running_before = _describe_running_children() | ||
| 821 | + if running_before: | ||
| 822 | + _log_shutdown_step("当前存活子进程: " + ", ".join(running_before)) | ||
| 823 | + else: | ||
| 824 | + _log_shutdown_step("未检测到存活子进程,仍将发送关闭指令") | ||
| 825 | + | ||
| 826 | + threads = [] | ||
| 827 | + | ||
| 828 | + # 并发关闭 Streamlit 子进程 | ||
| 829 | + for app_name in STREAMLIT_SCRIPTS: | ||
| 830 | + t = threading.Thread(target=stop_streamlit_app, args=(app_name,), daemon=True) | ||
| 831 | + threads.append(t) | ||
| 832 | + t.start() | ||
| 833 | + | ||
| 834 | + # 并发关闭 ForumEngine | ||
| 835 | + forum_thread = threading.Thread(target=stop_forum_engine, daemon=True) | ||
| 836 | + threads.append(forum_thread) | ||
| 837 | + forum_thread.start() | ||
| 838 | + | ||
| 839 | + # 等待所有线程完成,最多 timeout 秒 | ||
| 840 | + end_time = time.time() + timeout | ||
| 841 | + for t in threads: | ||
| 842 | + remaining = end_time - time.time() | ||
| 843 | + if remaining <= 0: | ||
| 844 | + break | ||
| 845 | + t.join(timeout=remaining) | ||
| 846 | + | ||
| 847 | + # 二次检查:强制杀掉仍存活的子进程 | ||
| 848 | + for app_name in STREAMLIT_SCRIPTS: | ||
| 849 | + proc = processes[app_name]['process'] | ||
| 850 | + if proc is not None and proc.poll() is None: | ||
| 851 | + try: | ||
| 852 | + _log_shutdown_step(f"{app_name} 进程仍存活,触发二次终止 (pid={proc.pid})") | ||
| 853 | + proc.terminate() | ||
| 854 | + proc.wait(timeout=1) | ||
| 855 | + except Exception: | ||
| 856 | + try: | ||
| 857 | + _log_shutdown_step(f"{app_name} 二次终止失败,尝试kill (pid={proc.pid})") | ||
| 858 | + proc.kill() | ||
| 859 | + proc.wait(timeout=1) | ||
| 860 | + except Exception: | ||
| 861 | + logger.warning(f"{app_name} 进程强制退出失败,继续关机") | ||
| 862 | + finally: | ||
| 863 | + processes[app_name]['process'] = None | ||
| 864 | + processes[app_name]['status'] = 'stopped' | ||
| 865 | + | ||
| 866 | + processes['forum']['status'] = 'stopped' | ||
| 867 | + _log_shutdown_step("并发清理结束,标记系统未启动") | ||
| 868 | + _set_system_state(started=False, starting=False) | ||
| 869 | + | ||
| 870 | +def _schedule_server_shutdown(delay_seconds: float = 0.1): | ||
| 871 | + """在清理完成后尽快退出,避免阻塞当前请求。""" | ||
| 872 | + def _shutdown(): | ||
| 873 | + time.sleep(delay_seconds) | ||
| 874 | + try: | ||
| 875 | + socketio.stop() | ||
| 876 | + except Exception as exc: # pragma: no cover | ||
| 877 | + logger.warning(f"SocketIO 停止时异常,继续退出: {exc}") | ||
| 878 | + _log_shutdown_step("SocketIO 停止指令已发送,即将退出主进程") | ||
| 879 | + os._exit(0) | ||
| 880 | + | ||
| 881 | + threading.Thread(target=_shutdown, daemon=True).start() | ||
| 882 | + | ||
| 883 | +def _start_async_shutdown(cleanup_timeout: float = 3.0): | ||
| 884 | + """异步触发清理并强制退出,避免HTTP请求阻塞。""" | ||
| 885 | + _log_shutdown_step(f"收到关机指令,启动异步清理(超时 {cleanup_timeout}s)") | ||
| 886 | + | ||
| 887 | + def _force_exit(): | ||
| 888 | + _log_shutdown_step("关机超时,触发强制退出") | ||
| 889 | + os._exit(0) | ||
| 890 | + | ||
| 891 | + # 硬超时保护,即便清理线程异常也能退出 | ||
| 892 | + hard_timeout = cleanup_timeout + 2.0 | ||
| 893 | + force_timer = threading.Timer(hard_timeout, _force_exit) | ||
| 894 | + force_timer.daemon = True | ||
| 895 | + force_timer.start() | ||
| 896 | + | ||
| 897 | + def _cleanup_and_exit(): | ||
| 898 | + try: | ||
| 899 | + cleanup_processes_concurrent(timeout=cleanup_timeout) | ||
| 900 | + except Exception as exc: # pragma: no cover | ||
| 901 | + logger.exception(f"关机清理异常: {exc}") | ||
| 902 | + finally: | ||
| 903 | + _log_shutdown_step("清理线程结束,调度主进程退出") | ||
| 904 | + _schedule_server_shutdown(0.05) | ||
| 905 | + | ||
| 906 | + threading.Thread(target=_cleanup_and_exit, daemon=True).start() | ||
| 907 | + | ||
| 779 | # 注册清理函数 | 908 | # 注册清理函数 |
| 780 | atexit.register(cleanup_processes) | 909 | atexit.register(cleanup_processes) |
| 781 | 910 | ||
| @@ -1124,6 +1253,48 @@ def start_system(): | @@ -1124,6 +1253,48 @@ def start_system(): | ||
| 1124 | finally: | 1253 | finally: |
| 1125 | _set_system_state(starting=False) | 1254 | _set_system_state(starting=False) |
| 1126 | 1255 | ||
| 1256 | +@app.route('/api/system/shutdown', methods=['POST']) | ||
| 1257 | +def shutdown_system(): | ||
| 1258 | + """优雅停止所有组件并关闭当前服务进程。""" | ||
| 1259 | + state = _get_system_state() | ||
| 1260 | + if state['starting']: | ||
| 1261 | + return jsonify({'success': False, 'message': '系统正在启动/重启,请稍候'}), 400 | ||
| 1262 | + | ||
| 1263 | + target_ports = [ | ||
| 1264 | + f"{name}:{info['port']}" | ||
| 1265 | + for name, info in processes.items() | ||
| 1266 | + if info.get('port') | ||
| 1267 | + ] | ||
| 1268 | + | ||
| 1269 | + # 已有关机请求执行中时,返回当前存活的子进程,便于前端判断进度 | ||
| 1270 | + if not _mark_shutdown_requested(): | ||
| 1271 | + running = _describe_running_children() | ||
| 1272 | + detail = '关机指令已下发,请稍等...' | ||
| 1273 | + if running: | ||
| 1274 | + detail = f"关机指令已下发,等待进程退出: {', '.join(running)}" | ||
| 1275 | + if target_ports: | ||
| 1276 | + detail = f"{detail}(端口: {', '.join(target_ports)})" | ||
| 1277 | + return jsonify({'success': True, 'message': detail, 'ports': target_ports}) | ||
| 1278 | + | ||
| 1279 | + running = _describe_running_children() | ||
| 1280 | + if running: | ||
| 1281 | + _log_shutdown_step("开始关闭系统,正在等待子进程退出: " + ", ".join(running)) | ||
| 1282 | + else: | ||
| 1283 | + _log_shutdown_step("开始关闭系统,未检测到存活子进程") | ||
| 1284 | + | ||
| 1285 | + try: | ||
| 1286 | + _set_system_state(started=False, starting=False) | ||
| 1287 | + _start_async_shutdown(cleanup_timeout=6.0) | ||
| 1288 | + message = '关闭系统指令已下发,正在停止进程' | ||
| 1289 | + if running: | ||
| 1290 | + message = f"{message}: {', '.join(running)}" | ||
| 1291 | + if target_ports: | ||
| 1292 | + message = f"{message}(端口: {', '.join(target_ports)})" | ||
| 1293 | + return jsonify({'success': True, 'message': message, 'ports': target_ports}) | ||
| 1294 | + except Exception as exc: # pragma: no cover - 兜底捕获 | ||
| 1295 | + logger.exception("系统关闭过程中出现异常") | ||
| 1296 | + return jsonify({'success': False, 'message': f'系统关闭异常: {exc}'}), 500 | ||
| 1297 | + | ||
| 1127 | @socketio.on('connect') | 1298 | @socketio.on('connect') |
| 1128 | def handle_connect(): | 1299 | def handle_connect(): |
| 1129 | """客户端连接""" | 1300 | """客户端连接""" |
| @@ -34,6 +34,60 @@ | @@ -34,6 +34,60 @@ | ||
| 34 | flex-direction: column; | 34 | flex-direction: column; |
| 35 | border: 2px solid #000000; | 35 | border: 2px solid #000000; |
| 36 | overflow: hidden; /* 防止整体滚动 */ | 36 | overflow: hidden; /* 防止整体滚动 */ |
| 37 | + position: relative; | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + .page-actions { | ||
| 41 | + position: absolute; | ||
| 42 | + top: 16px; | ||
| 43 | + right: 18px; | ||
| 44 | + display: flex; | ||
| 45 | + gap: 10px; | ||
| 46 | + z-index: 20; | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + .page-action-button { | ||
| 50 | + border: 2px solid #000000; | ||
| 51 | + background-color: #ffffff; | ||
| 52 | + color: #000000; | ||
| 53 | + padding: 10px 14px; | ||
| 54 | + font-weight: bold; | ||
| 55 | + cursor: pointer; | ||
| 56 | + box-shadow: 4px 4px 0 #000000; | ||
| 57 | + transition: transform 0.12s ease, box-shadow 0.12s ease, background-color 0.2s ease, color 0.2s ease; | ||
| 58 | + letter-spacing: 0.5px; | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + .page-action-button:hover { | ||
| 62 | + transform: translate(-2px, -2px); | ||
| 63 | + box-shadow: 6px 6px 0 #000000; | ||
| 64 | + background-color: #f5f5f5; | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + .page-action-button:disabled { | ||
| 68 | + opacity: 0.6; | ||
| 69 | + cursor: not-allowed; | ||
| 70 | + transform: none; | ||
| 71 | + box-shadow: 2px 2px 0 #777777; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + .page-action-button.refresh { | ||
| 75 | + background-color: #ffffff; | ||
| 76 | + color: #000000; | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + .page-action-button.shutdown { | ||
| 80 | + background-color: #000000; | ||
| 81 | + color: #ffffff; | ||
| 82 | + border-color: #000000; | ||
| 83 | + box-shadow: none; | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + .page-action-button.shutdown:hover { | ||
| 87 | + background-color: #1a1a1a; | ||
| 88 | + color: #ffffff; | ||
| 89 | + transform: none; | ||
| 90 | + box-shadow: none; | ||
| 37 | } | 91 | } |
| 38 | 92 | ||
| 39 | /* 搜索框区域 */ | 93 | /* 搜索框区域 */ |
| @@ -491,6 +545,23 @@ | @@ -491,6 +545,23 @@ | ||
| 491 | align-items: center; | 545 | align-items: center; |
| 492 | } | 546 | } |
| 493 | 547 | ||
| 548 | + .status-bar-left { | ||
| 549 | + display: flex; | ||
| 550 | + align-items: center; | ||
| 551 | + gap: 12px; | ||
| 552 | + } | ||
| 553 | + | ||
| 554 | + .status-shutdown-button { | ||
| 555 | + padding: 8px 16px; | ||
| 556 | + box-shadow: none; | ||
| 557 | + } | ||
| 558 | + | ||
| 559 | + .status-shutdown-button:hover, | ||
| 560 | + .status-shutdown-button:disabled { | ||
| 561 | + box-shadow: none; | ||
| 562 | + transform: none; | ||
| 563 | + } | ||
| 564 | + | ||
| 494 | .config-modal-overlay { | 565 | .config-modal-overlay { |
| 495 | position: fixed; | 566 | position: fixed; |
| 496 | inset: 0; | 567 | inset: 0; |
| @@ -916,6 +987,143 @@ | @@ -916,6 +987,143 @@ | ||
| 916 | background-color: #fff0f0; | 987 | background-color: #fff0f0; |
| 917 | } | 988 | } |
| 918 | 989 | ||
| 990 | + /* 系统关机确认弹窗 */ | ||
| 991 | + .confirm-overlay { | ||
| 992 | + position: fixed; | ||
| 993 | + inset: 0; | ||
| 994 | + background-color: rgba(0, 0, 0, 0.35); | ||
| 995 | + display: none; | ||
| 996 | + align-items: center; | ||
| 997 | + justify-content: center; | ||
| 998 | + z-index: 1200; | ||
| 999 | + padding: 20px; | ||
| 1000 | + } | ||
| 1001 | + | ||
| 1002 | + .confirm-overlay.visible { | ||
| 1003 | + display: flex; | ||
| 1004 | + } | ||
| 1005 | + | ||
| 1006 | + .confirm-dialog { | ||
| 1007 | + background-color: #ffffff; | ||
| 1008 | + border: 2px solid #000000; | ||
| 1009 | + width: 420px; | ||
| 1010 | + max-width: 90vw; | ||
| 1011 | + display: flex; | ||
| 1012 | + flex-direction: column; | ||
| 1013 | + } | ||
| 1014 | + | ||
| 1015 | + .confirm-dialog.danger { | ||
| 1016 | + border-color: #b42318; | ||
| 1017 | + } | ||
| 1018 | + | ||
| 1019 | + .confirm-header { | ||
| 1020 | + display: flex; | ||
| 1021 | + align-items: center; | ||
| 1022 | + justify-content: space-between; | ||
| 1023 | + padding: 14px 16px; | ||
| 1024 | + border-bottom: 2px solid #000000; | ||
| 1025 | + } | ||
| 1026 | + | ||
| 1027 | + .confirm-dialog.danger .confirm-header { | ||
| 1028 | + border-color: #b42318; | ||
| 1029 | + } | ||
| 1030 | + | ||
| 1031 | + .confirm-title { | ||
| 1032 | + font-size: 16px; | ||
| 1033 | + font-weight: bold; | ||
| 1034 | + } | ||
| 1035 | + | ||
| 1036 | + .confirm-close { | ||
| 1037 | + width: 30px; | ||
| 1038 | + height: 30px; | ||
| 1039 | + border: 2px solid #000000; | ||
| 1040 | + background-color: #ffffff; | ||
| 1041 | + cursor: pointer; | ||
| 1042 | + font-size: 18px; | ||
| 1043 | + line-height: 1; | ||
| 1044 | + display: flex; | ||
| 1045 | + align-items: center; | ||
| 1046 | + justify-content: center; | ||
| 1047 | + } | ||
| 1048 | + | ||
| 1049 | + .confirm-close:hover { | ||
| 1050 | + background-color: #000000; | ||
| 1051 | + color: #ffffff; | ||
| 1052 | + } | ||
| 1053 | + | ||
| 1054 | + .confirm-body { | ||
| 1055 | + padding: 16px; | ||
| 1056 | + border-bottom: 2px solid #000000; | ||
| 1057 | + } | ||
| 1058 | + | ||
| 1059 | + .confirm-dialog.danger .confirm-body { | ||
| 1060 | + border-color: #b42318; | ||
| 1061 | + } | ||
| 1062 | + | ||
| 1063 | + .confirm-text { | ||
| 1064 | + display: flex; | ||
| 1065 | + flex-direction: column; | ||
| 1066 | + gap: 8px; | ||
| 1067 | + } | ||
| 1068 | + | ||
| 1069 | + .confirm-list { | ||
| 1070 | + display: flex; | ||
| 1071 | + flex-direction: column; | ||
| 1072 | + gap: 6px; | ||
| 1073 | + } | ||
| 1074 | + | ||
| 1075 | + .confirm-pill { | ||
| 1076 | + padding: 4px 10px; | ||
| 1077 | + border: 2px solid #000000; | ||
| 1078 | + background-color: #f5f5f0; | ||
| 1079 | + font-size: 12px; | ||
| 1080 | + font-weight: bold; | ||
| 1081 | + } | ||
| 1082 | + | ||
| 1083 | + .confirm-dialog .confirm-button.danger { | ||
| 1084 | + background-color: #ffecec; | ||
| 1085 | + color: #b42318; | ||
| 1086 | + } | ||
| 1087 | + | ||
| 1088 | + .confirm-dialog .confirm-button.danger:hover { | ||
| 1089 | + background-color: #ffc9c9; | ||
| 1090 | + color: #8c1111; | ||
| 1091 | + } | ||
| 1092 | + | ||
| 1093 | + .shutdown-pill { | ||
| 1094 | + text-align: center; | ||
| 1095 | + display: inline-flex; | ||
| 1096 | + justify-content: center; | ||
| 1097 | + align-items: center; | ||
| 1098 | + } | ||
| 1099 | + | ||
| 1100 | + .confirm-subtext { | ||
| 1101 | + font-size: 12px; | ||
| 1102 | + color: #555555; | ||
| 1103 | + line-height: 1.5; | ||
| 1104 | + } | ||
| 1105 | + | ||
| 1106 | + .confirm-actions { | ||
| 1107 | + display: flex; | ||
| 1108 | + justify-content: flex-end; | ||
| 1109 | + gap: 10px; | ||
| 1110 | + padding: 12px 16px; | ||
| 1111 | + } | ||
| 1112 | + | ||
| 1113 | + .confirm-button { | ||
| 1114 | + padding: 9px 18px; | ||
| 1115 | + border: 2px solid #000000; | ||
| 1116 | + background-color: #ffffff; | ||
| 1117 | + cursor: pointer; | ||
| 1118 | + font-size: 13px; | ||
| 1119 | + font-weight: bold; | ||
| 1120 | + transition: all 0.3s ease; | ||
| 1121 | + } | ||
| 1122 | + | ||
| 1123 | + .confirm-button:hover { | ||
| 1124 | + background-color: #f0f0f0; | ||
| 1125 | + } | ||
| 1126 | + | ||
| 919 | /* Forum Engine 专用样式 */ | 1127 | /* Forum Engine 专用样式 */ |
| 920 | .forum-container { | 1128 | .forum-container { |
| 921 | display: none; | 1129 | display: none; |
| @@ -1331,8 +1539,31 @@ | @@ -1331,8 +1539,31 @@ | ||
| 1331 | 1539 | ||
| 1332 | <!-- 状态栏:实时展示WebSocket连接状态与系统时钟 --> | 1540 | <!-- 状态栏:实时展示WebSocket连接状态与系统时钟 --> |
| 1333 | <div class="status-bar"> | 1541 | <div class="status-bar"> |
| 1334 | - <span id="connectionStatus">连接中...</span> | ||
| 1335 | - <span id="systemTime"></span> | 1542 | + <div class="status-bar-left"> |
| 1543 | + <span id="connectionStatus">连接中...</span> | ||
| 1544 | + <span id="systemTime"></span> | ||
| 1545 | + </div> | ||
| 1546 | + <button class="page-action-button shutdown status-shutdown-button" id="shutdownButton" aria-label="关闭系统">关闭系统</button> | ||
| 1547 | + </div> | ||
| 1548 | + </div> | ||
| 1549 | + | ||
| 1550 | + <div class="confirm-overlay" id="shutdownConfirmModal" aria-hidden="true"> | ||
| 1551 | + <div class="confirm-dialog danger"> | ||
| 1552 | + <div class="confirm-header"> | ||
| 1553 | + <div class="confirm-title" id="shutdownStrongText">确定要关闭系统吗?</div> | ||
| 1554 | + <button class="confirm-close" id="closeShutdownConfirm" aria-label="关闭系统确认">×</button> | ||
| 1555 | + </div> | ||
| 1556 | + <div class="confirm-body"> | ||
| 1557 | + <div class="confirm-text"> | ||
| 1558 | + <div class="confirm-subtext" id="shutdownSubText">将关闭系统并停止由本控制台启动的子进程,涉及端口/进程:</div> | ||
| 1559 | + <div class="confirm-list" id="shutdownRunningList"></div> | ||
| 1560 | + <div class="confirm-list" id="shutdownPortList"></div> | ||
| 1561 | + </div> | ||
| 1562 | + </div> | ||
| 1563 | + <div class="confirm-actions"> | ||
| 1564 | + <button class="confirm-button" id="cancelShutdownButton">取消</button> | ||
| 1565 | + <button class="confirm-button danger" id="confirmShutdownButton">立即关闭系统</button> | ||
| 1566 | + </div> | ||
| 1336 | </div> | 1567 | </div> |
| 1337 | </div> | 1568 | </div> |
| 1338 | 1569 | ||
| @@ -1753,10 +1984,19 @@ | @@ -1753,10 +1984,19 @@ | ||
| 1753 | } | 1984 | } |
| 1754 | } | 1985 | } |
| 1755 | 1986 | ||
| 1987 | + let pageRefreshInProgress = false; | ||
| 1988 | + let shutdownInProgress = false; | ||
| 1989 | + | ||
| 1756 | const CONFIG_ENDPOINT = '/api/config'; | 1990 | const CONFIG_ENDPOINT = '/api/config'; |
| 1757 | const SYSTEM_STATUS_ENDPOINT = '/api/system/status'; | 1991 | const SYSTEM_STATUS_ENDPOINT = '/api/system/status'; |
| 1758 | const SYSTEM_START_ENDPOINT = '/api/system/start'; | 1992 | const SYSTEM_START_ENDPOINT = '/api/system/start'; |
| 1993 | + const SYSTEM_SHUTDOWN_ENDPOINT = '/api/system/shutdown'; | ||
| 1759 | const START_BUTTON_DEFAULT_TEXT = '保存并启动系统'; | 1994 | const START_BUTTON_DEFAULT_TEXT = '保存并启动系统'; |
| 1995 | + const APP_PORTS = { | ||
| 1996 | + insight: 8501, | ||
| 1997 | + media: 8502, | ||
| 1998 | + query: 8503 | ||
| 1999 | + }; | ||
| 1760 | 2000 | ||
| 1761 | const configFieldGroups = [ | 2001 | const configFieldGroups = [ |
| 1762 | { | 2002 | { |
| @@ -2002,6 +2242,34 @@ | @@ -2002,6 +2242,34 @@ | ||
| 2002 | startSystemButton.addEventListener('click', () => startSystem()); | 2242 | startSystemButton.addEventListener('click', () => startSystem()); |
| 2003 | } | 2243 | } |
| 2004 | 2244 | ||
| 2245 | + const refreshPageButton = document.getElementById('pageRefreshButton'); | ||
| 2246 | + if (refreshPageButton) { | ||
| 2247 | + refreshPageButton.addEventListener('click', () => handleSafeRefresh()); | ||
| 2248 | + } | ||
| 2249 | + | ||
| 2250 | + const shutdownButton = document.getElementById('shutdownButton'); | ||
| 2251 | + if (shutdownButton) { | ||
| 2252 | + shutdownButton.addEventListener('click', () => handleShutdownRequest()); | ||
| 2253 | + } | ||
| 2254 | + | ||
| 2255 | + const cancelShutdownButton = document.getElementById('cancelShutdownButton'); | ||
| 2256 | + if (cancelShutdownButton) { | ||
| 2257 | + cancelShutdownButton.addEventListener('click', () => hideShutdownConfirm()); | ||
| 2258 | + } | ||
| 2259 | + | ||
| 2260 | + const closeShutdownButton = document.getElementById('closeShutdownConfirm'); | ||
| 2261 | + if (closeShutdownButton) { | ||
| 2262 | + closeShutdownButton.addEventListener('click', () => hideShutdownConfirm()); | ||
| 2263 | + } | ||
| 2264 | + | ||
| 2265 | + const confirmShutdownButton = document.getElementById('confirmShutdownButton'); | ||
| 2266 | + if (confirmShutdownButton) { | ||
| 2267 | + confirmShutdownButton.addEventListener('click', () => { | ||
| 2268 | + hideShutdownConfirm(); | ||
| 2269 | + shutdownSystem({ skipAgentWarning: true }); | ||
| 2270 | + }); | ||
| 2271 | + } | ||
| 2272 | + | ||
| 2005 | const configModal = document.getElementById('configModal'); | 2273 | const configModal = document.getElementById('configModal'); |
| 2006 | if (configModal) { | 2274 | if (configModal) { |
| 2007 | configModal.addEventListener('click', (event) => { | 2275 | configModal.addEventListener('click', (event) => { |
| @@ -2020,8 +2288,14 @@ | @@ -2020,8 +2288,14 @@ | ||
| 2020 | } | 2288 | } |
| 2021 | 2289 | ||
| 2022 | document.addEventListener('keydown', function(event) { | 2290 | document.addEventListener('keydown', function(event) { |
| 2023 | - if (event.key === 'Escape' && isConfigModalVisible()) { | ||
| 2024 | - closeConfigModal(); | 2291 | + if (event.key === 'Escape') { |
| 2292 | + if (isConfigModalVisible()) { | ||
| 2293 | + closeConfigModal(); | ||
| 2294 | + } | ||
| 2295 | + const shutdownModal = document.getElementById('shutdownConfirmModal'); | ||
| 2296 | + if (shutdownModal && shutdownModal.classList.contains('visible')) { | ||
| 2297 | + hideShutdownConfirm(); | ||
| 2298 | + } | ||
| 2025 | } | 2299 | } |
| 2026 | }); | 2300 | }); |
| 2027 | } | 2301 | } |
| @@ -2494,6 +2768,154 @@ | @@ -2494,6 +2768,154 @@ | ||
| 2494 | } | 2768 | } |
| 2495 | } | 2769 | } |
| 2496 | 2770 | ||
| 2771 | + function getRunningAgents() { | ||
| 2772 | + return Object.keys(appStatus).filter(app => appStatus[app] === 'running'); | ||
| 2773 | + } | ||
| 2774 | + | ||
| 2775 | + function hideShutdownConfirm() { | ||
| 2776 | + const modal = document.getElementById('shutdownConfirmModal'); | ||
| 2777 | + if (modal) { | ||
| 2778 | + modal.classList.remove('visible'); | ||
| 2779 | + modal.setAttribute('aria-hidden', 'true'); | ||
| 2780 | + } | ||
| 2781 | + } | ||
| 2782 | + | ||
| 2783 | + function showShutdownConfirm(runningAgents = []) { | ||
| 2784 | + const modal = document.getElementById('shutdownConfirmModal'); | ||
| 2785 | + const list = document.getElementById('shutdownRunningList'); | ||
| 2786 | + const portList = document.getElementById('shutdownPortList'); | ||
| 2787 | + const strongText = document.getElementById('shutdownStrongText'); | ||
| 2788 | + | ||
| 2789 | + if (strongText) { | ||
| 2790 | + strongText.textContent = runningAgents.length > 0 | ||
| 2791 | + ? '部分 Agent 正在运行,确定要关闭吗?' | ||
| 2792 | + : '确定要关闭系统吗?'; | ||
| 2793 | + } | ||
| 2794 | + | ||
| 2795 | + if (list) { | ||
| 2796 | + list.innerHTML = ''; | ||
| 2797 | + list.style.display = 'none'; | ||
| 2798 | + } | ||
| 2799 | + | ||
| 2800 | + if (portList) { | ||
| 2801 | + const targets = Object.entries(APP_PORTS).map(([key, port]) => { | ||
| 2802 | + const status = appStatus[key] || 'unknown'; | ||
| 2803 | + const label = `${appNames[key] || key}${port ? `:${port}` : ''}`; | ||
| 2804 | + const suffix = status === 'running' ? '运行中' : '未运行'; | ||
| 2805 | + return `<span class="confirm-pill shutdown-pill">${label} · ${suffix}</span>`; | ||
| 2806 | + }); | ||
| 2807 | + portList.innerHTML = targets.length > 0 | ||
| 2808 | + ? targets.join('') | ||
| 2809 | + : '<span class="confirm-pill">暂无需要关闭的端口</span>'; | ||
| 2810 | + } | ||
| 2811 | + | ||
| 2812 | + if (modal) { | ||
| 2813 | + modal.classList.add('visible'); | ||
| 2814 | + modal.setAttribute('aria-hidden', 'false'); | ||
| 2815 | + } | ||
| 2816 | + } | ||
| 2817 | + | ||
| 2818 | + async function handleSafeRefresh() { | ||
| 2819 | + if (pageRefreshInProgress) { | ||
| 2820 | + return; | ||
| 2821 | + } | ||
| 2822 | + | ||
| 2823 | + pageRefreshInProgress = true; | ||
| 2824 | + const refreshButton = document.getElementById('pageRefreshButton'); | ||
| 2825 | + const originalText = refreshButton ? refreshButton.textContent : ''; | ||
| 2826 | + if (refreshButton) { | ||
| 2827 | + refreshButton.disabled = true; | ||
| 2828 | + refreshButton.textContent = '刷新中...'; | ||
| 2829 | + } | ||
| 2830 | + | ||
| 2831 | + try { | ||
| 2832 | + await fetchSystemStatus(); | ||
| 2833 | + await checkStatus(); | ||
| 2834 | + refreshConsoleOutput(); | ||
| 2835 | + showMessage('已刷新最新状态与日志', 'success'); | ||
| 2836 | + } catch (error) { | ||
| 2837 | + console.error('刷新页面数据失败', error); | ||
| 2838 | + showMessage(`刷新失败: ${error.message}`, 'error'); | ||
| 2839 | + } finally { | ||
| 2840 | + pageRefreshInProgress = false; | ||
| 2841 | + if (refreshButton) { | ||
| 2842 | + refreshButton.disabled = false; | ||
| 2843 | + refreshButton.textContent = originalText || '安全刷新'; | ||
| 2844 | + } | ||
| 2845 | + } | ||
| 2846 | + } | ||
| 2847 | + | ||
| 2848 | + async function handleShutdownRequest() { | ||
| 2849 | + if (shutdownInProgress) { | ||
| 2850 | + return; | ||
| 2851 | + } | ||
| 2852 | + | ||
| 2853 | + if (systemStarting) { | ||
| 2854 | + showMessage('系统正在启动/重启,请稍后再关闭', 'error'); | ||
| 2855 | + return; | ||
| 2856 | + } | ||
| 2857 | + | ||
| 2858 | + const runningAgents = getRunningAgents(); | ||
| 2859 | + if (runningAgents.length > 0) { | ||
| 2860 | + showShutdownConfirm(runningAgents); | ||
| 2861 | + return; | ||
| 2862 | + } | ||
| 2863 | + | ||
| 2864 | + shutdownSystem({ skipAgentWarning: true }); | ||
| 2865 | + } | ||
| 2866 | + | ||
| 2867 | + async function shutdownSystem(options = {}) { | ||
| 2868 | + const { skipAgentWarning = false } = options; | ||
| 2869 | + | ||
| 2870 | + if (shutdownInProgress) { | ||
| 2871 | + return; | ||
| 2872 | + } | ||
| 2873 | + | ||
| 2874 | + if (!skipAgentWarning) { | ||
| 2875 | + const runningAgents = getRunningAgents(); | ||
| 2876 | + if (runningAgents.length > 0) { | ||
| 2877 | + showShutdownConfirm(runningAgents); | ||
| 2878 | + return; | ||
| 2879 | + } | ||
| 2880 | + } | ||
| 2881 | + | ||
| 2882 | + shutdownInProgress = true; | ||
| 2883 | + const button = document.getElementById('shutdownButton'); | ||
| 2884 | + const originalText = button ? button.textContent : ''; | ||
| 2885 | + if (button) { | ||
| 2886 | + button.disabled = true; | ||
| 2887 | + button.textContent = '关闭中...'; | ||
| 2888 | + } | ||
| 2889 | + | ||
| 2890 | + try { | ||
| 2891 | + const controller = new AbortController(); | ||
| 2892 | + const timeoutId = setTimeout(() => controller.abort(), 4000); | ||
| 2893 | + const response = await fetch(SYSTEM_SHUTDOWN_ENDPOINT, { method: 'POST', signal: controller.signal }); | ||
| 2894 | + clearTimeout(timeoutId); | ||
| 2895 | + | ||
| 2896 | + const message = '系统正在停止,请稍候...'; | ||
| 2897 | + if (!response.ok) { | ||
| 2898 | + throw new Error(`服务返回 ${response.status}`); | ||
| 2899 | + } | ||
| 2900 | + | ||
| 2901 | + setConfigStatus(message, 'success'); | ||
| 2902 | + showMessage(message, 'success'); | ||
| 2903 | + } catch (error) { | ||
| 2904 | + const text = error.name === 'AbortError' | ||
| 2905 | + ? '停止指令已发送,请稍候退出' | ||
| 2906 | + : `停止失败: ${error.message}`; | ||
| 2907 | + showMessage(text, error.name === 'AbortError' ? 'success' : 'error'); | ||
| 2908 | + | ||
| 2909 | + if (error.name !== 'AbortError') { | ||
| 2910 | + shutdownInProgress = false; | ||
| 2911 | + if (button) { | ||
| 2912 | + button.disabled = false; | ||
| 2913 | + button.textContent = originalText || '关闭系统'; | ||
| 2914 | + } | ||
| 2915 | + } | ||
| 2916 | + } | ||
| 2917 | + } | ||
| 2918 | + | ||
| 2497 | async function startSystem() { | 2919 | async function startSystem() { |
| 2498 | if (systemStarting) { | 2920 | if (systemStarting) { |
| 2499 | setConfigStatus('系统正在启动,请稍候...', ''); | 2921 | setConfigStatus('系统正在启动,请稍候...', ''); |
-
Please register or login to post a comment