fix: 完善服务重启交互
This commit is contained in:
+25
-27
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` 调整设置页字段映射卡片头部布局,“字段映射配置”和字段数量标签不再被搜索框挤压换行。
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user