fix: 完善服务重启交互

This commit is contained in:
2026-05-18 17:34:23 +08:00
parent d91d2f91fa
commit 3750160891
6 changed files with 201 additions and 36 deletions
+25 -27
View File
@@ -1,13 +1,14 @@
import os import os
import platform import platform
import subprocess
import time import time
from threading import Thread from threading import Thread
from fastapi import APIRouter from fastapi import APIRouter
from app.services.runtime import ( from app.services.runtime import (
is_container_runtime,
is_supervisor_running, is_supervisor_running,
restart_method_name,
restart_via_supervisor, restart_via_supervisor,
terminate_current_process, terminate_current_process,
) )
@@ -18,38 +19,35 @@ router = APIRouter(tags=["service"])
@router.post("/api/service/restart") @router.post("/api/service/restart")
async def restart_service(): async def restart_service():
if is_supervisor_running(): supervisor_running = is_supervisor_running()
container_runtime = is_container_runtime()
if supervisor_running:
success, message = restart_via_supervisor() success, message = restart_via_supervisor()
return {"success": success, "message": message, "method": "supervisor"} if success:
if platform.system() != "Windows":
try:
result = subprocess.run(
["supervisorctl", "restart", "fastapi"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
return {
"success": True,
"message": "服务正在通过 supervisor 重启...",
"method": "supervisor",
}
except FileNotFoundError:
pass
except subprocess.TimeoutExpired:
return { return {
"success": False, "success": True,
"message": "重启操作超时,请检查 supervisor 状态", "message": message,
"method": "supervisor", "method": "supervisor",
"container": container_runtime,
} }
except Exception:
pass
method = restart_method_name(supervisor_running)
thread = Thread(target=_delayed_exit, daemon=True) thread = Thread(target=_delayed_exit, daemon=True)
thread.start() thread.start()
return {"success": True, "message": "服务正在重启,请稍后刷新页面...", "method": "signal"}
message = "服务正在重启,请稍后刷新页面..."
if supervisor_running:
message = "supervisor 命令不可用,已切换为进程重启..."
elif container_runtime:
message = "服务进程正在退出,容器或进程管理器将拉起服务..."
return {
"success": True,
"message": message,
"method": method,
"container": container_runtime,
}
@router.get("/api/service/status") @router.get("/api/service/status")
@@ -59,6 +57,7 @@ async def get_service_status():
"version": "2.0.2", "version": "2.0.2",
"platform": platform.system(), "platform": platform.system(),
"supervisor": is_supervisor_running(), "supervisor": is_supervisor_running(),
"container": is_container_runtime(),
"pid": os.getpid(), "pid": os.getpid(),
"python_version": platform.python_version(), "python_version": platform.python_version(),
} }
@@ -67,4 +66,3 @@ async def get_service_status():
def _delayed_exit() -> None: def _delayed_exit() -> None:
time.sleep(1) time.sleep(1)
terminate_current_process() terminate_current_process()
+28 -1
View File
@@ -25,6 +25,24 @@ def is_supervisor_running() -> bool:
return _command_succeeds(["pgrep", "-f", "supervisord"], timeout=2) return _command_succeeds(["pgrep", "-f", "supervisord"], timeout=2)
def is_container_runtime() -> bool:
if os.environ.get("container"):
return True
if Path("/.dockerenv").exists():
return True
cgroup_path = Path("/proc/1/cgroup")
if not cgroup_path.exists():
return False
try:
cgroup = cgroup_path.read_text(encoding="utf-8", errors="ignore").lower()
except Exception:
return False
return any(marker in cgroup for marker in ("docker", "kubepods", "containerd"))
def restart_via_supervisor() -> tuple[bool, str]: def restart_via_supervisor() -> tuple[bool, str]:
try: try:
result = subprocess.run( result = subprocess.run(
@@ -52,10 +70,19 @@ def terminate_current_process() -> None:
os.kill(os.getpid(), signal.SIGTERM) os.kill(os.getpid(), signal.SIGTERM)
def restart_method_name(supervisor_running: bool) -> str:
if supervisor_running:
return "supervisor"
if platform.system() == "Windows":
return "windows-process"
if is_container_runtime():
return "container-process"
return "process"
def _command_succeeds(command: list[str], timeout: int) -> bool: def _command_succeeds(command: list[str], timeout: int) -> bool:
try: try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout) result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
return result.returncode == 0 and bool(result.stdout.strip() or command[0] == "supervisorctl") return result.returncode == 0 and bool(result.stdout.strip() or command[0] == "supervisorctl")
except Exception: except Exception:
return False return False
+8
View File
@@ -1,5 +1,13 @@
# 项目上下文记录 # 项目上下文记录
## 2026-05-18:完善服务重启交互和运行时兼容
- `frontend/src/AppShell.vue` 的重启按钮点击后会先弹出确认框,确认后显示全屏“正在重启服务”遮罩和旋转加载动画,避免用户重复操作。
- 重启请求发出后前端会轮询 `/api/service/status`,服务恢复时自动刷新页面;重启过程中请求中断会被视为正常情况继续等待。
- `app/api/routers/service.py` 和 `app/services/runtime.py` 统一重启逻辑:优先使用 supervisor 重启,失败时退回进程退出;Windows 本地依赖 `run.bat` 循环拉起,容器环境会识别 Docker/containerd/k8s 并交给 supervisor 或容器重启策略拉起。
- `/api/service/status` 返回值新增 `container` 字段,用于前端或排障区分容器运行时。
- 已执行 `.venv\Scripts\python.exe -m compileall app` 和 `npm run build`,均通过;未启动浏览器或 headless Chrome。
## 2026-05-18:修正字段映射标题换行 ## 2026-05-18:修正字段映射标题换行
- `frontend/src/styles.css` 调整设置页字段映射卡片头部布局,“字段映射配置”和字段数量标签不再被搜索框挤压换行。 - `frontend/src/styles.css` 调整设置页字段映射卡片头部布局,“字段映射配置”和字段数量标签不再被搜索框挤压换行。
+79 -8
View File
@@ -63,7 +63,13 @@
<button class="theme-toggle" type="button" :title="themeToggleTitle" @click="toggleTheme"> <button class="theme-toggle" type="button" :title="themeToggleTitle" @click="toggleTheme">
<n-icon><component :is="themeToggleIcon" /></n-icon> <n-icon><component :is="themeToggleIcon" /></n-icon>
</button> </button>
<button class="restart-btn" type="button" title="重启服务" @click="restartService"> <button
class="restart-btn"
type="button"
title="重启服务"
:disabled="restarting"
@click="restartService"
>
<n-icon><PowerOutline /></n-icon> <n-icon><PowerOutline /></n-icon>
</button> </button>
<n-button tertiary circle title="退出登录" class="logout-button" @click="logout"> <n-button tertiary circle title="退出登录" class="logout-button" @click="logout">
@@ -79,12 +85,19 @@
</n-layout-content> </n-layout-content>
</n-layout> </n-layout>
</n-layout> </n-layout>
<div v-if="restarting" class="restart-overlay" role="status" aria-live="assertive">
<div class="restart-overlay-content">
<div class="restart-overlay-spinner"></div>
<div class="restart-overlay-text">{{ restartOverlayText }}</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue'; import { computed, h, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router'; import { RouterView, useRoute, useRouter } from 'vue-router';
import { useMessage, type MenuOption, NIcon } from 'naive-ui'; import { useDialog, useMessage, type MenuOption, NIcon } from 'naive-ui';
import { import {
CloudUploadOutline, CloudUploadOutline,
ConstructOutline, ConstructOutline,
@@ -97,20 +110,24 @@ import {
SunnyOutline SunnyOutline
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { apiPost, clearToken, getToken, setToken, setUnauthorizedHandler } from './api/client'; import { apiGet, apiPost, clearToken, getToken, setToken, setUnauthorizedHandler } from './api/client';
import type { LoginResponse } from './types'; import type { ApiMessage, LoginResponse, ServiceStatus } from './types';
import LoginView from './components/LoginView.vue'; import LoginView from './components/LoginView.vue';
import { pageHeader, resetPageHeader, resolveHeaderValue, type PageHeaderAction } from './composables/pageHeader'; import { pageHeader, resetPageHeader, resolveHeaderValue, type PageHeaderAction } from './composables/pageHeader';
import { themeName, toggleAppTheme } from './composables/theme'; import { themeName, toggleAppTheme } from './composables/theme';
const message = useMessage(); const message = useMessage();
const dialog = useDialog();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const token = ref(getToken()); const token = ref(getToken());
const loginLoading = ref(false); const loginLoading = ref(false);
const restarting = ref(false);
const restartOverlayText = ref('正在重启服务...');
const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true'); const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true');
const menuKeys = ['workflow', 'history', 'database', 'script', 'settings'] as const; const menuKeys = ['workflow', 'history', 'database', 'script', 'settings'] as const;
type MenuKey = (typeof menuKeys)[number]; type MenuKey = (typeof menuKeys)[number];
let restartPollTimer: number | undefined;
const menuOptions: MenuOption[] = [ const menuOptions: MenuOption[] = [
{ label: '数据上传', key: 'workflow', icon: renderIcon(CloudUploadOutline) }, { label: '数据上传', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
@@ -139,6 +156,9 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('dragover', preventWindowFileDrop, { capture: true }); window.removeEventListener('dragover', preventWindowFileDrop, { capture: true });
window.removeEventListener('drop', preventWindowFileDrop, { capture: true }); window.removeEventListener('drop', preventWindowFileDrop, { capture: true });
if (restartPollTimer !== undefined) {
window.clearTimeout(restartPollTimer);
}
}); });
watch( watch(
@@ -180,12 +200,63 @@ function toggleTheme() {
} }
async function restartService() { async function restartService() {
if (restarting.value) return;
dialog.warning({
title: '重启服务',
content: '确定要重启服务吗?这将中断当前所有操作。',
positiveText: '重启',
negativeText: '取消',
onPositiveClick: () => {
void executeRestartService();
}
});
}
async function executeRestartService() {
restarting.value = true;
restartOverlayText.value = '正在重启服务...';
try { try {
await apiPost('/api/service/restart'); const result = await apiPost<ApiMessage>('/api/service/restart');
message.success('服务正在重启'); restartOverlayText.value = result.message || '正在等待服务恢复...';
} catch (error) { } catch {
message.error(error instanceof Error ? error.message : '重启服务失败'); restartOverlayText.value = '正在等待服务恢复...';
} }
pollServiceStatus();
}
function pollServiceStatus() {
let attempts = 0;
const maxAttempts = 60;
const pollInterval = 5000;
const checkService = async () => {
attempts += 1;
try {
await apiGet<ServiceStatus>('/api/service/status');
restartOverlayText.value = '服务已恢复,正在刷新页面...';
restartPollTimer = window.setTimeout(() => {
window.location.reload();
}, 500);
return;
} catch {
restartOverlayText.value = `正在等待服务恢复... (${attempts}/${maxAttempts})`;
}
if (attempts < maxAttempts) {
restartPollTimer = window.setTimeout(checkService, pollInterval);
return;
}
restarting.value = false;
restartOverlayText.value = '正在重启服务...';
message.warning('服务重启超时,请手动刷新页面');
};
restartPollTimer = window.setTimeout(checkService, 3000);
} }
function handleMenuChange(key: string | number) { function handleMenuChange(key: string | number) {
+60
View File
@@ -26,8 +26,10 @@
--td-bg-color-component-disabled: #eeeeee; --td-bg-color-component-disabled: #eeeeee;
--td-border-color: #dcdcdc; --td-border-color: #dcdcdc;
--td-border-color-light: #e7e7e7; --td-border-color-light: #e7e7e7;
--td-mask-color: rgba(0, 0, 0, 0.6);
--td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.05), 0 4px 5px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.12); --td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.05), 0 4px 5px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.12);
--td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.05), 0 8px 10px 1px rgba(0, 0, 0, 0.06), 0 5px 5px -3px rgba(0, 0, 0, 0.1); --td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.05), 0 8px 10px 1px rgba(0, 0, 0, 0.06), 0 5px 5px -3px rgba(0, 0, 0, 0.1);
--td-shadow-3: 0 6px 30px 5px rgba(0, 0, 0, 0.05), 0 16px 24px 2px rgba(0, 0, 0, 0.04), 0 8px 10px -5px rgba(0, 0, 0, 0.08);
--td-radius-small: 3px; --td-radius-small: 3px;
--td-radius-default: 6px; --td-radius-default: 6px;
--td-radius-large: 12px; --td-radius-large: 12px;
@@ -61,6 +63,7 @@
--td-border-color-light: #4c4c4c; --td-border-color-light: #4c4c4c;
--td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.15), 0 4px 5px rgba(0, 0, 0, 0.22), 0 2px 4px -1px rgba(0, 0, 0, 0.32); --td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.15), 0 4px 5px rgba(0, 0, 0, 0.22), 0 2px 4px -1px rgba(0, 0, 0, 0.32);
--td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.15), 0 8px 10px 1px rgba(0, 0, 0, 0.18), 0 5px 5px -3px rgba(0, 0, 0, 0.28); --td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.15), 0 8px 10px 1px rgba(0, 0, 0, 0.18), 0 5px 5px -3px rgba(0, 0, 0, 0.28);
--td-shadow-3: 0 6px 30px 5px rgba(0, 0, 0, 0.15), 0 16px 24px 2px rgba(0, 0, 0, 0.12), 0 8px 10px -5px rgba(0, 0, 0, 0.24);
} }
/* ==================== Base ==================== */ /* ==================== Base ==================== */
@@ -373,6 +376,11 @@ select {
background: var(--td-error-color-light); background: var(--td-error-color-light);
} }
.restart-btn:disabled {
cursor: wait;
opacity: 0.7;
}
.theme-toggle svg, .theme-toggle svg,
.restart-btn svg { .restart-btn svg {
display: block; display: block;
@@ -380,6 +388,58 @@ select {
height: 16px; height: 16px;
} }
.restart-overlay {
position: fixed;
inset: 0;
z-index: 5000;
display: flex;
align-items: center;
justify-content: center;
background: var(--td-mask-color);
backdrop-filter: blur(4px);
}
.restart-overlay-content {
display: flex;
min-width: 260px;
align-items: center;
flex-direction: column;
gap: 18px;
padding: 32px 44px;
color: var(--td-text-color-primary);
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-color-light);
border-radius: var(--td-radius-large);
box-shadow: var(--td-shadow-3);
}
.restart-overlay-spinner {
width: 44px;
height: 44px;
border: 4px solid var(--td-border-color-light);
border-top-color: var(--td-brand-color);
border-radius: 50%;
animation: spinner-rotate 1s linear infinite;
}
.restart-overlay-text {
color: var(--td-text-color-primary);
font-size: 15px;
font-weight: 500;
line-height: 22px;
text-align: center;
}
@keyframes spinner-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.logout-button { .logout-button {
flex-shrink: 0; flex-shrink: 0;
} }
+1
View File
@@ -104,6 +104,7 @@ export interface ServiceStatus {
version: string; version: string;
platform: string; platform: string;
supervisor: boolean; supervisor: boolean;
container: boolean;
pid: number; pid: number;
python_version: string; python_version: string;
} }