浩彬
Committed by GitHub

Shutdown only clean 增加安全关闭系统的功能 (#441)

* feat: add shutdown endpoint and minimal UI

* feat(ui): 添加系统关闭确认弹窗和安全刷新功能

- 新增系统关闭确认弹窗,显示运行中的服务和端口状态
- 添加安全刷新按钮,优化状态更新流程
- 重构关机逻辑,增加超时处理和错误恢复机制
- 改进状态栏布局和操作按钮样式

* feat(ui): update shutdown messages in index.html
... ... @@ -343,4 +343,6 @@ OperationGuidance/
insight_engine_streamlit_reports/
media_engine_streamlit_reports/
query_engine_streamlit_reports/
final_reports/
\ No newline at end of file
final_reports/
forward_prs.sh
变更报告/屏幕录制 2025-12-03 094707.mp4
... ...
... ... @@ -226,7 +226,8 @@ def write_config_values(updates):
system_state_lock = threading.Lock()
system_state = {
'started': False,
'starting': False
'starting': False,
'shutdown_in_progress': False
}
... ... @@ -255,6 +256,14 @@ def _prepare_system_start():
system_state['starting'] = True
return True, None
def _mark_shutdown_requested():
"""标记关机已请求;若已有关机流程则返回 False。"""
with system_state_lock:
if system_state.get('shutdown_in_progress'):
return False
system_state['shutdown_in_progress'] = True
return True
def initialize_system_components():
"""启动所有依赖组件(Streamlit 子应用、ForumEngine、ReportEngine)。"""
... ... @@ -500,6 +509,21 @@ STREAMLIT_SCRIPTS = {
'query': 'SingleEngineApp/query_engine_streamlit_app.py'
}
def _log_shutdown_step(message: str):
"""统一记录关机步骤,便于排查。"""
logger.info(f"[Shutdown] {message}")
def _describe_running_children():
"""列出当前存活的子进程。"""
running = []
for name, info in processes.items():
proc = info.get('process')
if proc is not None and proc.poll() is None:
port_desc = f", port={info.get('port')}" if info.get('port') else ""
running.append(f"{name}(pid={proc.pid}{port_desc})")
return running
# 输出队列
output_queues = {
'insight': Queue(),
... ... @@ -683,18 +707,28 @@ def start_streamlit_app(app_name, script_path, port):
def stop_streamlit_app(app_name):
"""停止Streamlit应用"""
try:
if processes[app_name]['process'] is None:
process = processes[app_name]['process']
if process is None:
_log_shutdown_step(f"{app_name} 未运行,跳过停止")
return False, "应用未运行"
process = processes[app_name]['process']
try:
pid = process.pid
except Exception:
pid = 'unknown'
_log_shutdown_step(f"正在停止 {app_name} (pid={pid})")
process.terminate()
# 等待进程结束
try:
process.wait(timeout=5)
_log_shutdown_step(f"{app_name} 退出完成,returncode={process.returncode}")
except subprocess.TimeoutExpired:
_log_shutdown_step(f"{app_name} 终止超时,尝试强制结束 (pid={pid})")
process.kill()
process.wait()
_log_shutdown_step(f"{app_name} 已强制结束,returncode={process.returncode}")
processes[app_name]['process'] = None
processes[app_name]['status'] = 'stopped'
... ... @@ -702,6 +736,7 @@ def stop_streamlit_app(app_name):
return True, f"{app_name} 应用已停止"
except Exception as e:
_log_shutdown_step(f"{app_name} 停止失败: {e}")
return False, f"停止失败: {str(e)}"
HEALTHCHECK_PATH = "/_stcore/health"
... ... @@ -766,6 +801,7 @@ def wait_for_app_startup(app_name, max_wait_time=90):
def cleanup_processes():
"""清理所有进程"""
_log_shutdown_step("开始串行清理子进程")
for app_name in STREAMLIT_SCRIPTS:
stop_streamlit_app(app_name)
... ... @@ -774,8 +810,101 @@ def cleanup_processes():
stop_forum_engine()
except Exception: # pragma: no cover
logger.exception("停止ForumEngine失败")
_log_shutdown_step("子进程清理完成")
_set_system_state(started=False, starting=False)
def cleanup_processes_concurrent(timeout: float = 6.0):
"""并发清理所有子进程,超时后强制杀掉残留进程。"""
_log_shutdown_step(f"开始并发清理子进程(超时 {timeout}s)")
_log_shutdown_step("仅终止当前控制台启动并记录的子进程,不做端口扫描")
running_before = _describe_running_children()
if running_before:
_log_shutdown_step("当前存活子进程: " + ", ".join(running_before))
else:
_log_shutdown_step("未检测到存活子进程,仍将发送关闭指令")
threads = []
# 并发关闭 Streamlit 子进程
for app_name in STREAMLIT_SCRIPTS:
t = threading.Thread(target=stop_streamlit_app, args=(app_name,), daemon=True)
threads.append(t)
t.start()
# 并发关闭 ForumEngine
forum_thread = threading.Thread(target=stop_forum_engine, daemon=True)
threads.append(forum_thread)
forum_thread.start()
# 等待所有线程完成,最多 timeout 秒
end_time = time.time() + timeout
for t in threads:
remaining = end_time - time.time()
if remaining <= 0:
break
t.join(timeout=remaining)
# 二次检查:强制杀掉仍存活的子进程
for app_name in STREAMLIT_SCRIPTS:
proc = processes[app_name]['process']
if proc is not None and proc.poll() is None:
try:
_log_shutdown_step(f"{app_name} 进程仍存活,触发二次终止 (pid={proc.pid})")
proc.terminate()
proc.wait(timeout=1)
except Exception:
try:
_log_shutdown_step(f"{app_name} 二次终止失败,尝试kill (pid={proc.pid})")
proc.kill()
proc.wait(timeout=1)
except Exception:
logger.warning(f"{app_name} 进程强制退出失败,继续关机")
finally:
processes[app_name]['process'] = None
processes[app_name]['status'] = 'stopped'
processes['forum']['status'] = 'stopped'
_log_shutdown_step("并发清理结束,标记系统未启动")
_set_system_state(started=False, starting=False)
def _schedule_server_shutdown(delay_seconds: float = 0.1):
"""在清理完成后尽快退出,避免阻塞当前请求。"""
def _shutdown():
time.sleep(delay_seconds)
try:
socketio.stop()
except Exception as exc: # pragma: no cover
logger.warning(f"SocketIO 停止时异常,继续退出: {exc}")
_log_shutdown_step("SocketIO 停止指令已发送,即将退出主进程")
os._exit(0)
threading.Thread(target=_shutdown, daemon=True).start()
def _start_async_shutdown(cleanup_timeout: float = 3.0):
"""异步触发清理并强制退出,避免HTTP请求阻塞。"""
_log_shutdown_step(f"收到关机指令,启动异步清理(超时 {cleanup_timeout}s)")
def _force_exit():
_log_shutdown_step("关机超时,触发强制退出")
os._exit(0)
# 硬超时保护,即便清理线程异常也能退出
hard_timeout = cleanup_timeout + 2.0
force_timer = threading.Timer(hard_timeout, _force_exit)
force_timer.daemon = True
force_timer.start()
def _cleanup_and_exit():
try:
cleanup_processes_concurrent(timeout=cleanup_timeout)
except Exception as exc: # pragma: no cover
logger.exception(f"关机清理异常: {exc}")
finally:
_log_shutdown_step("清理线程结束,调度主进程退出")
_schedule_server_shutdown(0.05)
threading.Thread(target=_cleanup_and_exit, daemon=True).start()
# 注册清理函数
atexit.register(cleanup_processes)
... ... @@ -1124,6 +1253,48 @@ def start_system():
finally:
_set_system_state(starting=False)
@app.route('/api/system/shutdown', methods=['POST'])
def shutdown_system():
"""优雅停止所有组件并关闭当前服务进程。"""
state = _get_system_state()
if state['starting']:
return jsonify({'success': False, 'message': '系统正在启动/重启,请稍候'}), 400
target_ports = [
f"{name}:{info['port']}"
for name, info in processes.items()
if info.get('port')
]
# 已有关机请求执行中时,返回当前存活的子进程,便于前端判断进度
if not _mark_shutdown_requested():
running = _describe_running_children()
detail = '关机指令已下发,请稍等...'
if running:
detail = f"关机指令已下发,等待进程退出: {', '.join(running)}"
if target_ports:
detail = f"{detail}(端口: {', '.join(target_ports)})"
return jsonify({'success': True, 'message': detail, 'ports': target_ports})
running = _describe_running_children()
if running:
_log_shutdown_step("开始关闭系统,正在等待子进程退出: " + ", ".join(running))
else:
_log_shutdown_step("开始关闭系统,未检测到存活子进程")
try:
_set_system_state(started=False, starting=False)
_start_async_shutdown(cleanup_timeout=6.0)
message = '关闭系统指令已下发,正在停止进程'
if running:
message = f"{message}: {', '.join(running)}"
if target_ports:
message = f"{message}(端口: {', '.join(target_ports)})"
return jsonify({'success': True, 'message': message, 'ports': target_ports})
except Exception as exc: # pragma: no cover - 兜底捕获
logger.exception("系统关闭过程中出现异常")
return jsonify({'success': False, 'message': f'系统关闭异常: {exc}'}), 500
@socketio.on('connect')
def handle_connect():
"""客户端连接"""
... ...
... ... @@ -34,6 +34,60 @@
flex-direction: column;
border: 2px solid #000000;
overflow: hidden; /* 防止整体滚动 */
position: relative;
}
.page-actions {
position: absolute;
top: 16px;
right: 18px;
display: flex;
gap: 10px;
z-index: 20;
}
.page-action-button {
border: 2px solid #000000;
background-color: #ffffff;
color: #000000;
padding: 10px 14px;
font-weight: bold;
cursor: pointer;
box-shadow: 4px 4px 0 #000000;
transition: transform 0.12s ease, box-shadow 0.12s ease, background-color 0.2s ease, color 0.2s ease;
letter-spacing: 0.5px;
}
.page-action-button:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 #000000;
background-color: #f5f5f5;
}
.page-action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: 2px 2px 0 #777777;
}
.page-action-button.refresh {
background-color: #ffffff;
color: #000000;
}
.page-action-button.shutdown {
background-color: #000000;
color: #ffffff;
border-color: #000000;
box-shadow: none;
}
.page-action-button.shutdown:hover {
background-color: #1a1a1a;
color: #ffffff;
transform: none;
box-shadow: none;
}
/* 搜索框区域 */
... ... @@ -491,6 +545,23 @@
align-items: center;
}
.status-bar-left {
display: flex;
align-items: center;
gap: 12px;
}
.status-shutdown-button {
padding: 8px 16px;
box-shadow: none;
}
.status-shutdown-button:hover,
.status-shutdown-button:disabled {
box-shadow: none;
transform: none;
}
.config-modal-overlay {
position: fixed;
inset: 0;
... ... @@ -916,6 +987,143 @@
background-color: #fff0f0;
}
/* 系统关机确认弹窗 */
.confirm-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.35);
display: none;
align-items: center;
justify-content: center;
z-index: 1200;
padding: 20px;
}
.confirm-overlay.visible {
display: flex;
}
.confirm-dialog {
background-color: #ffffff;
border: 2px solid #000000;
width: 420px;
max-width: 90vw;
display: flex;
flex-direction: column;
}
.confirm-dialog.danger {
border-color: #b42318;
}
.confirm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 2px solid #000000;
}
.confirm-dialog.danger .confirm-header {
border-color: #b42318;
}
.confirm-title {
font-size: 16px;
font-weight: bold;
}
.confirm-close {
width: 30px;
height: 30px;
border: 2px solid #000000;
background-color: #ffffff;
cursor: pointer;
font-size: 18px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-close:hover {
background-color: #000000;
color: #ffffff;
}
.confirm-body {
padding: 16px;
border-bottom: 2px solid #000000;
}
.confirm-dialog.danger .confirm-body {
border-color: #b42318;
}
.confirm-text {
display: flex;
flex-direction: column;
gap: 8px;
}
.confirm-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.confirm-pill {
padding: 4px 10px;
border: 2px solid #000000;
background-color: #f5f5f0;
font-size: 12px;
font-weight: bold;
}
.confirm-dialog .confirm-button.danger {
background-color: #ffecec;
color: #b42318;
}
.confirm-dialog .confirm-button.danger:hover {
background-color: #ffc9c9;
color: #8c1111;
}
.shutdown-pill {
text-align: center;
display: inline-flex;
justify-content: center;
align-items: center;
}
.confirm-subtext {
font-size: 12px;
color: #555555;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 16px;
}
.confirm-button {
padding: 9px 18px;
border: 2px solid #000000;
background-color: #ffffff;
cursor: pointer;
font-size: 13px;
font-weight: bold;
transition: all 0.3s ease;
}
.confirm-button:hover {
background-color: #f0f0f0;
}
/* Forum Engine 专用样式 */
.forum-container {
display: none;
... ... @@ -1331,8 +1539,31 @@
<!-- 状态栏:实时展示WebSocket连接状态与系统时钟 -->
<div class="status-bar">
<span id="connectionStatus">连接中...</span>
<span id="systemTime"></span>
<div class="status-bar-left">
<span id="connectionStatus">连接中...</span>
<span id="systemTime"></span>
</div>
<button class="page-action-button shutdown status-shutdown-button" id="shutdownButton" aria-label="关闭系统">关闭系统</button>
</div>
</div>
<div class="confirm-overlay" id="shutdownConfirmModal" aria-hidden="true">
<div class="confirm-dialog danger">
<div class="confirm-header">
<div class="confirm-title" id="shutdownStrongText">确定要关闭系统吗?</div>
<button class="confirm-close" id="closeShutdownConfirm" aria-label="关闭系统确认">×</button>
</div>
<div class="confirm-body">
<div class="confirm-text">
<div class="confirm-subtext" id="shutdownSubText">将关闭系统并停止由本控制台启动的子进程,涉及端口/进程:</div>
<div class="confirm-list" id="shutdownRunningList"></div>
<div class="confirm-list" id="shutdownPortList"></div>
</div>
</div>
<div class="confirm-actions">
<button class="confirm-button" id="cancelShutdownButton">取消</button>
<button class="confirm-button danger" id="confirmShutdownButton">立即关闭系统</button>
</div>
</div>
</div>
... ... @@ -1753,10 +1984,19 @@
}
}
let pageRefreshInProgress = false;
let shutdownInProgress = false;
const CONFIG_ENDPOINT = '/api/config';
const SYSTEM_STATUS_ENDPOINT = '/api/system/status';
const SYSTEM_START_ENDPOINT = '/api/system/start';
const SYSTEM_SHUTDOWN_ENDPOINT = '/api/system/shutdown';
const START_BUTTON_DEFAULT_TEXT = '保存并启动系统';
const APP_PORTS = {
insight: 8501,
media: 8502,
query: 8503
};
const configFieldGroups = [
{
... ... @@ -2002,6 +2242,34 @@
startSystemButton.addEventListener('click', () => startSystem());
}
const refreshPageButton = document.getElementById('pageRefreshButton');
if (refreshPageButton) {
refreshPageButton.addEventListener('click', () => handleSafeRefresh());
}
const shutdownButton = document.getElementById('shutdownButton');
if (shutdownButton) {
shutdownButton.addEventListener('click', () => handleShutdownRequest());
}
const cancelShutdownButton = document.getElementById('cancelShutdownButton');
if (cancelShutdownButton) {
cancelShutdownButton.addEventListener('click', () => hideShutdownConfirm());
}
const closeShutdownButton = document.getElementById('closeShutdownConfirm');
if (closeShutdownButton) {
closeShutdownButton.addEventListener('click', () => hideShutdownConfirm());
}
const confirmShutdownButton = document.getElementById('confirmShutdownButton');
if (confirmShutdownButton) {
confirmShutdownButton.addEventListener('click', () => {
hideShutdownConfirm();
shutdownSystem({ skipAgentWarning: true });
});
}
const configModal = document.getElementById('configModal');
if (configModal) {
configModal.addEventListener('click', (event) => {
... ... @@ -2020,8 +2288,14 @@
}
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && isConfigModalVisible()) {
closeConfigModal();
if (event.key === 'Escape') {
if (isConfigModalVisible()) {
closeConfigModal();
}
const shutdownModal = document.getElementById('shutdownConfirmModal');
if (shutdownModal && shutdownModal.classList.contains('visible')) {
hideShutdownConfirm();
}
}
});
}
... ... @@ -2494,6 +2768,154 @@
}
}
function getRunningAgents() {
return Object.keys(appStatus).filter(app => appStatus[app] === 'running');
}
function hideShutdownConfirm() {
const modal = document.getElementById('shutdownConfirmModal');
if (modal) {
modal.classList.remove('visible');
modal.setAttribute('aria-hidden', 'true');
}
}
function showShutdownConfirm(runningAgents = []) {
const modal = document.getElementById('shutdownConfirmModal');
const list = document.getElementById('shutdownRunningList');
const portList = document.getElementById('shutdownPortList');
const strongText = document.getElementById('shutdownStrongText');
if (strongText) {
strongText.textContent = runningAgents.length > 0
? '部分 Agent 正在运行,确定要关闭吗?'
: '确定要关闭系统吗?';
}
if (list) {
list.innerHTML = '';
list.style.display = 'none';
}
if (portList) {
const targets = Object.entries(APP_PORTS).map(([key, port]) => {
const status = appStatus[key] || 'unknown';
const label = `${appNames[key] || key}${port ? `:${port}` : ''}`;
const suffix = status === 'running' ? '运行中' : '未运行';
return `<span class="confirm-pill shutdown-pill">${label} · ${suffix}</span>`;
});
portList.innerHTML = targets.length > 0
? targets.join('')
: '<span class="confirm-pill">暂无需要关闭的端口</span>';
}
if (modal) {
modal.classList.add('visible');
modal.setAttribute('aria-hidden', 'false');
}
}
async function handleSafeRefresh() {
if (pageRefreshInProgress) {
return;
}
pageRefreshInProgress = true;
const refreshButton = document.getElementById('pageRefreshButton');
const originalText = refreshButton ? refreshButton.textContent : '';
if (refreshButton) {
refreshButton.disabled = true;
refreshButton.textContent = '刷新中...';
}
try {
await fetchSystemStatus();
await checkStatus();
refreshConsoleOutput();
showMessage('已刷新最新状态与日志', 'success');
} catch (error) {
console.error('刷新页面数据失败', error);
showMessage(`刷新失败: ${error.message}`, 'error');
} finally {
pageRefreshInProgress = false;
if (refreshButton) {
refreshButton.disabled = false;
refreshButton.textContent = originalText || '安全刷新';
}
}
}
async function handleShutdownRequest() {
if (shutdownInProgress) {
return;
}
if (systemStarting) {
showMessage('系统正在启动/重启,请稍后再关闭', 'error');
return;
}
const runningAgents = getRunningAgents();
if (runningAgents.length > 0) {
showShutdownConfirm(runningAgents);
return;
}
shutdownSystem({ skipAgentWarning: true });
}
async function shutdownSystem(options = {}) {
const { skipAgentWarning = false } = options;
if (shutdownInProgress) {
return;
}
if (!skipAgentWarning) {
const runningAgents = getRunningAgents();
if (runningAgents.length > 0) {
showShutdownConfirm(runningAgents);
return;
}
}
shutdownInProgress = true;
const button = document.getElementById('shutdownButton');
const originalText = button ? button.textContent : '';
if (button) {
button.disabled = true;
button.textContent = '关闭中...';
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
const response = await fetch(SYSTEM_SHUTDOWN_ENDPOINT, { method: 'POST', signal: controller.signal });
clearTimeout(timeoutId);
const message = '系统正在停止,请稍候...';
if (!response.ok) {
throw new Error(`服务返回 ${response.status}`);
}
setConfigStatus(message, 'success');
showMessage(message, 'success');
} catch (error) {
const text = error.name === 'AbortError'
? '停止指令已发送,请稍候退出'
: `停止失败: ${error.message}`;
showMessage(text, error.name === 'AbortError' ? 'success' : 'error');
if (error.name !== 'AbortError') {
shutdownInProgress = false;
if (button) {
button.disabled = false;
button.textContent = originalText || '关闭系统';
}
}
}
}
async function startSystem() {
if (systemStarting) {
setConfigStatus('系统正在启动,请稍候...', '');
... ...