fix: add controlled service restart console
This commit is contained in:
@@ -12,6 +12,7 @@ from app.api.v1 import (
|
|||||||
collected_data,
|
collected_data,
|
||||||
visualization,
|
visualization,
|
||||||
bgp,
|
bgp,
|
||||||
|
system_control,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -27,5 +28,6 @@ api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
|||||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
|
api_router.include_router(system_control.router, prefix="/system", tags=["system"])
|
||||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||||
|
|||||||
167
backend/app/api/v1/system_control.py
Normal file
167
backend/app/api/v1/system_control.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import ROOT_DIR
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.system_control import (
|
||||||
|
build_task_id,
|
||||||
|
clear_active_task_id,
|
||||||
|
get_active_task_id,
|
||||||
|
get_allowed_command,
|
||||||
|
get_runner_script_path,
|
||||||
|
is_task_stale,
|
||||||
|
get_task_logs,
|
||||||
|
require_super_admin,
|
||||||
|
serialize_task,
|
||||||
|
set_active_task_id,
|
||||||
|
upsert_task_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class RestartTaskCreate(BaseModel):
|
||||||
|
action: str
|
||||||
|
|
||||||
|
|
||||||
|
class RestartTaskResponse(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
action: str
|
||||||
|
status: str
|
||||||
|
stage: str
|
||||||
|
message: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
requested_by: dict[str, object] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RestartTaskLogsResponse(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
lines: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_super_admin(current_user: User) -> None:
|
||||||
|
if not require_super_admin(current_user.role):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only super_admin can restart services",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
||||||
|
async def create_restart_task(
|
||||||
|
payload: RestartTaskCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
|
||||||
|
command = get_allowed_command(payload.action)
|
||||||
|
if command is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Unsupported system action",
|
||||||
|
)
|
||||||
|
|
||||||
|
active_task_id = get_active_task_id()
|
||||||
|
if active_task_id:
|
||||||
|
active_task = serialize_task(active_task_id)
|
||||||
|
if active_task and is_task_stale(active_task):
|
||||||
|
upsert_task_state(
|
||||||
|
active_task_id,
|
||||||
|
status="failed",
|
||||||
|
stage="failed",
|
||||||
|
message="Previous restart task became stale and was released",
|
||||||
|
)
|
||||||
|
clear_active_task_id(active_task_id)
|
||||||
|
elif active_task and active_task.get("status") in {"queued", "running"}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Another restart task is already in progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id = build_task_id()
|
||||||
|
requested_by = {"id": current_user.id, "username": current_user.username}
|
||||||
|
task_state = upsert_task_state(
|
||||||
|
task_id,
|
||||||
|
action=payload.action,
|
||||||
|
status="queued",
|
||||||
|
stage="accepted",
|
||||||
|
message="Restart task accepted",
|
||||||
|
requested_by=requested_by,
|
||||||
|
)
|
||||||
|
set_active_task_id(task_id)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
backend_path = str(ROOT_DIR / "backend")
|
||||||
|
existing_pythonpath = env.get("PYTHONPATH", "")
|
||||||
|
env["PYTHONPATH"] = (
|
||||||
|
f"{backend_path}{os.pathsep}{existing_pythonpath}"
|
||||||
|
if existing_pythonpath
|
||||||
|
else backend_path
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(get_runner_script_path()),
|
||||||
|
"--task-id",
|
||||||
|
task_id,
|
||||||
|
"--action",
|
||||||
|
payload.action,
|
||||||
|
],
|
||||||
|
cwd=str(ROOT_DIR),
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
task_state = upsert_task_state(
|
||||||
|
task_id,
|
||||||
|
action=payload.action,
|
||||||
|
status="failed",
|
||||||
|
stage="failed",
|
||||||
|
message=f"Unable to start restart runner: {exc}",
|
||||||
|
requested_by=requested_by,
|
||||||
|
)
|
||||||
|
clear_active_task_id(task_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=task_state["message"],
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return task_state
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/restart-tasks/{task_id}", response_model=RestartTaskResponse)
|
||||||
|
async def get_restart_task(
|
||||||
|
task_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
|
||||||
|
task = serialize_task(task_id)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/restart-tasks/{task_id}/logs", response_model=RestartTaskLogsResponse)
|
||||||
|
async def get_restart_task_logs(
|
||||||
|
task_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
|
||||||
|
task = serialize_task(task_id)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
|
||||||
|
return {"task_id": task_id, "lines": get_task_logs(task_id)}
|
||||||
174
backend/app/services/system_control.py
Normal file
174
backend/app/services/system_control.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import ROOT_DIR
|
||||||
|
from app.core.security import redis_client
|
||||||
|
|
||||||
|
SYSTEM_TASK_TTL_SECONDS = 24 * 60 * 60
|
||||||
|
SYSTEM_TASK_LOG_LIMIT = 100
|
||||||
|
SYSTEM_TASK_ACTIVE_KEY = "system:restart_task:active"
|
||||||
|
SYSTEM_TASK_STALE_SECONDS = 5 * 60
|
||||||
|
|
||||||
|
ALLOWED_ACTIONS: dict[str, dict[str, Any]] = {
|
||||||
|
"restart-backend": {
|
||||||
|
"command": ["./planet.sh", "restart", "-b"],
|
||||||
|
"recovery_mode": "backend",
|
||||||
|
},
|
||||||
|
"restart-database": {
|
||||||
|
"command": ["./planet.sh", "restart", "-d"],
|
||||||
|
"recovery_mode": "database",
|
||||||
|
},
|
||||||
|
"restart-system": {
|
||||||
|
"command": ["./planet.sh", "restart"],
|
||||||
|
"recovery_mode": "system",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_user_role(role: Any) -> str:
|
||||||
|
return role.value if hasattr(role, "value") else str(role)
|
||||||
|
|
||||||
|
|
||||||
|
def require_super_admin(user_role: Any) -> bool:
|
||||||
|
return normalize_user_role(user_role) == "super_admin"
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_id(prefix: str = "restart") -> str:
|
||||||
|
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
||||||
|
return f"{prefix}_{timestamp}_{secrets.token_hex(3)}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_key(task_id: str) -> str:
|
||||||
|
return f"system:restart_task:{task_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_logs_key(task_id: str) -> str:
|
||||||
|
return f"{get_task_key(task_id)}:logs"
|
||||||
|
|
||||||
|
|
||||||
|
def get_allowed_command(action: str) -> list[str] | None:
|
||||||
|
config = ALLOWED_ACTIONS.get(action)
|
||||||
|
if config is None:
|
||||||
|
return None
|
||||||
|
return list(config["command"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_action_recovery_mode(action: str) -> str | None:
|
||||||
|
config = ALLOWED_ACTIONS.get(action)
|
||||||
|
if config is None:
|
||||||
|
return None
|
||||||
|
return str(config["recovery_mode"])
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_task(task_id: str) -> dict[str, Any] | None:
|
||||||
|
payload = redis_client.hgetall(get_task_key(task_id))
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if payload.get("requested_by"):
|
||||||
|
try:
|
||||||
|
payload["requested_by"] = json.loads(payload["requested_by"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload["requested_by"] = {"username": payload["requested_by"]}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def append_task_log(task_id: str, line: str) -> None:
|
||||||
|
logs_key = get_task_logs_key(task_id)
|
||||||
|
redis_client.rpush(logs_key, line)
|
||||||
|
redis_client.ltrim(logs_key, -SYSTEM_TASK_LOG_LIMIT, -1)
|
||||||
|
redis_client.expire(logs_key, SYSTEM_TASK_TTL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_task_state(
|
||||||
|
task_id: str,
|
||||||
|
*,
|
||||||
|
action: str | None = None,
|
||||||
|
status: str,
|
||||||
|
stage: str,
|
||||||
|
message: str,
|
||||||
|
requested_by: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
existing = serialize_task(task_id) or {}
|
||||||
|
now = utc_now_iso()
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"action": action or existing.get("action") or "",
|
||||||
|
"status": status,
|
||||||
|
"stage": stage,
|
||||||
|
"message": message,
|
||||||
|
"created_at": existing.get("created_at") or now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if requested_by is not None:
|
||||||
|
payload["requested_by"] = requested_by
|
||||||
|
elif existing.get("requested_by") is not None:
|
||||||
|
payload["requested_by"] = existing["requested_by"]
|
||||||
|
|
||||||
|
redis_payload = {
|
||||||
|
key: json.dumps(value, ensure_ascii=False) if key == "requested_by" else str(value)
|
||||||
|
for key, value in payload.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
task_key = get_task_key(task_id)
|
||||||
|
redis_client.hset(task_key, mapping=redis_payload)
|
||||||
|
redis_client.expire(task_key, SYSTEM_TASK_TTL_SECONDS)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_logs(task_id: str) -> list[str]:
|
||||||
|
return [str(item) for item in redis_client.lrange(get_task_logs_key(task_id), 0, -1)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_task_id() -> str | None:
|
||||||
|
value = redis_client.get(SYSTEM_TASK_ACTIVE_KEY)
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_task_id(task_id: str) -> None:
|
||||||
|
redis_client.set(SYSTEM_TASK_ACTIVE_KEY, task_id, ex=SYSTEM_TASK_TTL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_active_task_id(task_id: str) -> None:
|
||||||
|
current = get_active_task_id()
|
||||||
|
if current == task_id:
|
||||||
|
redis_client.delete(SYSTEM_TASK_ACTIVE_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_task_timestamp(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_task_stale(task: dict[str, Any], *, max_age_seconds: int = SYSTEM_TASK_STALE_SECONDS) -> bool:
|
||||||
|
if task.get("status") not in {"queued", "running"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
updated_at = parse_task_timestamp(str(task.get("updated_at") or ""))
|
||||||
|
if updated_at is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if updated_at.tzinfo is None:
|
||||||
|
updated_at = updated_at.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
return datetime.now(UTC) - updated_at > timedelta(seconds=max_age_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def get_runner_script_path() -> Path:
|
||||||
|
return ROOT_DIR / "backend" / "scripts" / "system_restart_runner.py"
|
||||||
182
backend/scripts/system_restart_runner.py
Normal file
182
backend/scripts/system_restart_runner.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
BACKEND_DIR = ROOT_DIR / "backend"
|
||||||
|
|
||||||
|
if str(BACKEND_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_DIR))
|
||||||
|
|
||||||
|
from app.services.system_control import ( # noqa: E402
|
||||||
|
append_task_log,
|
||||||
|
clear_active_task_id,
|
||||||
|
get_allowed_command,
|
||||||
|
get_action_recovery_mode,
|
||||||
|
upsert_task_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--task-id", required=True)
|
||||||
|
parser.add_argument("--action", required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_http(url: str, timeout_seconds: int = 90, interval_seconds: float = 2.0) -> bool:
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
success_streak = 0
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urlopen(url, timeout=2) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
success_streak += 1
|
||||||
|
if success_streak >= 2:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
success_streak = 0
|
||||||
|
except URLError:
|
||||||
|
success_streak = 0
|
||||||
|
except Exception:
|
||||||
|
success_streak = 0
|
||||||
|
|
||||||
|
time.sleep(interval_seconds)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_recovery(action: str) -> tuple[bool, str]:
|
||||||
|
recovery_mode = get_action_recovery_mode(action)
|
||||||
|
if recovery_mode == "backend":
|
||||||
|
return wait_for_http("http://localhost:8000/health"), "backend health recovery"
|
||||||
|
if recovery_mode == "database":
|
||||||
|
return True, "database container restart completion"
|
||||||
|
if recovery_mode == "system":
|
||||||
|
backend_ok = wait_for_http("http://localhost:8000/health")
|
||||||
|
frontend_ok = wait_for_http("http://localhost:3000")
|
||||||
|
return backend_ok and frontend_ok, "system service recovery"
|
||||||
|
return False, "unsupported recovery mode"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
command = get_allowed_command(args.action)
|
||||||
|
recovery_mode = get_action_recovery_mode(args.action)
|
||||||
|
if command is None or recovery_mode is None:
|
||||||
|
upsert_task_state(
|
||||||
|
args.task_id,
|
||||||
|
action=args.action,
|
||||||
|
status="failed",
|
||||||
|
stage="failed",
|
||||||
|
message="Unsupported system action",
|
||||||
|
)
|
||||||
|
clear_active_task_id(args.task_id)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
upsert_task_state(
|
||||||
|
args.task_id,
|
||||||
|
action=args.action,
|
||||||
|
status="running",
|
||||||
|
stage="spawning",
|
||||||
|
message="Spawning restart command",
|
||||||
|
)
|
||||||
|
append_task_log(args.task_id, f"accepted {args.action} request")
|
||||||
|
append_task_log(args.task_id, f"resolved command: {' '.join(command)}")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PATH"] = f"{Path.home() / '.bun' / 'bin'}:{Path.home() / '.local' / 'bin'}:{env.get('PATH', '')}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
upsert_task_state(
|
||||||
|
args.task_id,
|
||||||
|
action=args.action,
|
||||||
|
status="running",
|
||||||
|
stage="stopping",
|
||||||
|
message="Restart command is running",
|
||||||
|
)
|
||||||
|
append_task_log(args.task_id, "restart command started")
|
||||||
|
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=str(ROOT_DIR),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if completed.stdout.strip():
|
||||||
|
for line in completed.stdout.strip().splitlines()[-20:]:
|
||||||
|
append_task_log(args.task_id, line)
|
||||||
|
if completed.stderr.strip():
|
||||||
|
for line in completed.stderr.strip().splitlines()[-20:]:
|
||||||
|
append_task_log(args.task_id, line)
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
upsert_task_state(
|
||||||
|
args.task_id,
|
||||||
|
action=args.action,
|
||||||
|
status="failed",
|
||||||
|
stage="failed",
|
||||||
|
message=f"Restart command failed with exit code {completed.returncode}",
|
||||||
|
)
|
||||||
|
clear_active_task_id(args.task_id)
|
||||||
|
return completed.returncode
|
||||||
|
|
||||||
|
upsert_task_state(
|
||||||
|
args.task_id,
|
||||||
|
action=args.action,
|
||||||
|
status="running",
|
||||||
|
stage="waiting_for_health",
|
||||||
|
message=f"Waiting for {recovery_mode} recovery",
|
||||||
|
)
|
||||||
|
append_task_log(args.task_id, f"waiting for {recovery_mode} recovery")
|
||||||
|
|
||||||
|
recovered, recovery_label = wait_for_recovery(args.action)
|
||||||
|
if recovered:
|
||||||
|
upsert_task_state(
|
||||||
|
args.task_id,
|
||||||
|
action=args.action,
|
||||||
|
status="succeeded",
|
||||||
|
stage="healthy",
|
||||||
|
message="Restart completed successfully",
|
||||||
|
)
|
||||||
|
append_task_log(args.task_id, f"{recovery_label} completed")
|
||||||
|
clear_active_task_id(args.task_id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
upsert_task_state(
|
||||||
|
args.task_id,
|
||||||
|
action=args.action,
|
||||||
|
status="timeout",
|
||||||
|
stage="failed",
|
||||||
|
message="Restart timed out waiting for recovery",
|
||||||
|
)
|
||||||
|
append_task_log(args.task_id, f"{recovery_label} timed out")
|
||||||
|
clear_active_task_id(args.task_id)
|
||||||
|
return 2
|
||||||
|
except Exception as exc:
|
||||||
|
append_task_log(args.task_id, f"runner exception: {exc}")
|
||||||
|
upsert_task_state(
|
||||||
|
args.task_id,
|
||||||
|
action=args.action,
|
||||||
|
status="failed",
|
||||||
|
stage="failed",
|
||||||
|
message=f"Restart runner failed: {exc}",
|
||||||
|
)
|
||||||
|
clear_active_task_id(args.task_id)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -7,6 +7,33 @@ This project follows the repository versioning rule:
|
|||||||
- `feature` -> `+0.1.0`
|
- `feature` -> `+0.1.0`
|
||||||
- `bugfix` -> `+0.0.1`
|
- `bugfix` -> `+0.0.1`
|
||||||
|
|
||||||
|
## 0.22.3
|
||||||
|
|
||||||
|
Released: 2026-03-31
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Added a controlled restart console on the dashboard so `super_admin` users can trigger backend, database, or full-system restarts from the UI.
|
||||||
|
- Unified restart operations behind `planet.sh` semantics, including a new database-only restart flag and stale task recovery for interrupted restart jobs.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `/api/v1/system/restart-tasks` task creation, task status, and task log endpoints in [system_control.py](/home/ray/dev/linkong/planet/backend/app/api/v1/system_control.py).
|
||||||
|
- Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
|
||||||
|
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||||
|
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
|
||||||
|
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/system-service-control.md).
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Improved the dashboard control surface in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx) with restart action selection, guided full-restart terminal output, persisted task log polling, and clearer modal layout.
|
||||||
|
- Improved dashboard restart styling in [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) with a dedicated toolbar, terminal panel, and scoped action button styles.
|
||||||
|
- Improved Vite dev proxy coverage in [vite.config.ts](/home/ray/dev/linkong/planet/frontend/vite.config.ts) so dashboard recovery polling can reach backend health checks during local development.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed restart-task deadlocks by automatically releasing stale in-progress restart tasks before accepting a new one.
|
||||||
|
|
||||||
## 0.21.9
|
## 0.21.9
|
||||||
|
|
||||||
Released: 2026-03-30
|
Released: 2026-03-30
|
||||||
|
|||||||
347
docs/system-service-control.md
Normal file
347
docs/system-service-control.md
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
# System Service Control
|
||||||
|
|
||||||
|
This document defines the fixed mapping between admin control-plane actions and
|
||||||
|
the existing `planet.sh` service-management commands.
|
||||||
|
|
||||||
|
The goal is to reuse the current operational script semantics without exposing
|
||||||
|
arbitrary shell execution to the frontend or API callers.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- This mapping is for admin-side operational controls only.
|
||||||
|
- The control plane must submit a fixed action name, not a raw shell command.
|
||||||
|
- The backend is responsible for translating an allowed action into a fixed
|
||||||
|
`planet.sh` invocation.
|
||||||
|
|
||||||
|
## Design Rules
|
||||||
|
|
||||||
|
- Only whitelist actions may be executed.
|
||||||
|
- The frontend must never send arbitrary shell strings.
|
||||||
|
- The backend must build command arguments from a fixed mapping table.
|
||||||
|
- High-risk actions should be restricted to `super_admin`.
|
||||||
|
- Prefer partial restarts over full-stack restarts when UI continuity matters.
|
||||||
|
|
||||||
|
## Action Mapping
|
||||||
|
|
||||||
|
| Action name | Intended use | `planet.sh` command | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `restart-backend` | Restart backend API only | `./planet.sh restart -b` | Recommended first implementation for UI-triggered restart flows. |
|
||||||
|
| `restart-database` | Restart PostgreSQL and Redis containers | `./planet.sh restart -d` | Useful when database/cache services need a controlled bounce without restarting the UI. |
|
||||||
|
| `restart-system` | Restart the whole application stack | `./planet.sh restart` | Frontend continuity breaks briefly; UI should switch to guided recovery mode. |
|
||||||
|
| `restart-frontend` | Restart frontend dev server only | `./planet.sh restart -f` | Use with caution; UI continuity is weaker than backend-only restart. |
|
||||||
|
| `restart-backend-port` | Restart backend on a specific port | `./planet.sh restart -b <port>` | Port must be backend-validated before execution. |
|
||||||
|
| `restart-frontend-port` | Restart frontend on a specific port | `./planet.sh restart -f <port>` | Port must be backend-validated before execution. |
|
||||||
|
| `health-check` | Read current service health | `./planet.sh health` | Safe read-only operational action. |
|
||||||
|
| `show-logs-backend` | Inspect backend logs | `./planet.sh log -b` | Best used for CLI/operator tooling, not normal Web UI streaming. |
|
||||||
|
| `show-logs-frontend` | Inspect frontend logs | `./planet.sh log -f` | Best used for CLI/operator tooling, not normal Web UI streaming. |
|
||||||
|
|
||||||
|
## Not Exposed In UI By Default
|
||||||
|
|
||||||
|
The following existing script capabilities should not be exposed directly in the
|
||||||
|
Web UI unless there is an explicit product need and an additional safety review:
|
||||||
|
|
||||||
|
- `./planet.sh restart`
|
||||||
|
- `./planet.sh start`
|
||||||
|
- `./planet.sh stop`
|
||||||
|
- `./planet.sh createuser`
|
||||||
|
- any future raw shell passthrough
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
|
||||||
|
- full restart can break the current control session;
|
||||||
|
- stop/start have larger blast radius;
|
||||||
|
- user creation is not a service-control operation;
|
||||||
|
- raw shell passthrough creates unnecessary privilege risk.
|
||||||
|
|
||||||
|
## Recommended First-Phase UI Contract
|
||||||
|
|
||||||
|
### Frontend action payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "restart-backend"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend command resolution
|
||||||
|
|
||||||
|
```text
|
||||||
|
restart-backend -> ["./planet.sh", "restart", "-b"]
|
||||||
|
restart-database -> ["./planet.sh", "restart", "-d"]
|
||||||
|
restart-system -> ["./planet.sh", "restart"]
|
||||||
|
restart-frontend -> ["./planet.sh", "restart", "-f"]
|
||||||
|
health-check -> ["./planet.sh", "health"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Draft
|
||||||
|
|
||||||
|
### Primary Endpoint
|
||||||
|
|
||||||
|
- `POST /api/v1/system/restart-tasks`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- create a controlled restart task;
|
||||||
|
- resolve a whitelist action into a fixed `planet.sh` command;
|
||||||
|
- hand execution off to an external runner or detached subprocess.
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "restart-backend"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional future shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "restart-backend-port",
|
||||||
|
"port": 8000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "restart_20260331_153000_ab12cd",
|
||||||
|
"action": "restart-backend",
|
||||||
|
"status": "queued",
|
||||||
|
"stage": "accepted",
|
||||||
|
"message": "Restart task accepted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Query Endpoint
|
||||||
|
|
||||||
|
- `GET /api/v1/system/restart-tasks/{task_id}`
|
||||||
|
|
||||||
|
Response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "restart_20260331_153000_ab12cd",
|
||||||
|
"action": "restart-backend",
|
||||||
|
"status": "queued",
|
||||||
|
"stage": "accepted",
|
||||||
|
"message": "Waiting for execution",
|
||||||
|
"requested_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin"
|
||||||
|
},
|
||||||
|
"created_at": "2026-03-31T15:30:00+08:00",
|
||||||
|
"updated_at": "2026-03-31T15:30:02+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Log Endpoint
|
||||||
|
|
||||||
|
- `GET /api/v1/system/restart-tasks/{task_id}/logs`
|
||||||
|
|
||||||
|
Suggested response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "restart_20260331_153000_ab12cd",
|
||||||
|
"lines": [
|
||||||
|
"accepted restart-backend request",
|
||||||
|
"spawning restart command",
|
||||||
|
"waiting for backend shutdown",
|
||||||
|
"waiting for backend health recovery"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This log endpoint is optional for phase one. The first version can work with
|
||||||
|
task state plus `/health` polling alone.
|
||||||
|
|
||||||
|
## Task State Model
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
- `queued`
|
||||||
|
- `running`
|
||||||
|
- `succeeded`
|
||||||
|
- `failed`
|
||||||
|
- `timeout`
|
||||||
|
|
||||||
|
### Stage
|
||||||
|
|
||||||
|
- `accepted`
|
||||||
|
- `spawning`
|
||||||
|
- `stopping`
|
||||||
|
- `starting`
|
||||||
|
- `waiting_for_health`
|
||||||
|
- `healthy`
|
||||||
|
- `failed`
|
||||||
|
|
||||||
|
### Interpretation
|
||||||
|
|
||||||
|
- `status` is the high-level terminal or non-terminal state.
|
||||||
|
- `stage` is the operator-facing execution phase for the UI.
|
||||||
|
- `message` is the short human-readable line shown in the modal or full-screen
|
||||||
|
overlay.
|
||||||
|
|
||||||
|
## Permission Model
|
||||||
|
|
||||||
|
- `restart-backend` should require `super_admin`.
|
||||||
|
- Permission checks should follow the same role pattern already used in
|
||||||
|
[users.py](/home/ray/dev/linkong/planet/backend/app/api/v1/users.py).
|
||||||
|
- Frontend visibility may hide controls for non-`super_admin`, but backend must
|
||||||
|
still enforce authorization.
|
||||||
|
|
||||||
|
## Storage Model
|
||||||
|
|
||||||
|
Recommended first implementation:
|
||||||
|
|
||||||
|
- store restart task state in Redis;
|
||||||
|
- keep task lifetime short;
|
||||||
|
- keep recent logs as a bounded list.
|
||||||
|
|
||||||
|
Suggested keys:
|
||||||
|
|
||||||
|
- `system:restart_task:{task_id}`
|
||||||
|
- `system:restart_task:{task_id}:logs`
|
||||||
|
|
||||||
|
Suggested stored fields:
|
||||||
|
|
||||||
|
- `task_id`
|
||||||
|
- `action`
|
||||||
|
- `status`
|
||||||
|
- `stage`
|
||||||
|
- `message`
|
||||||
|
- `requested_by_id`
|
||||||
|
- `requested_by_username`
|
||||||
|
- `created_at`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
## Execution Model
|
||||||
|
|
||||||
|
The request-handling API process should not depend on itself surviving long
|
||||||
|
enough to stream the whole restart output.
|
||||||
|
|
||||||
|
Recommended execution flow:
|
||||||
|
|
||||||
|
1. validate caller and action
|
||||||
|
2. create task state in Redis
|
||||||
|
3. resolve action to fixed `planet.sh` argv
|
||||||
|
4. spawn detached executor
|
||||||
|
5. return `task_id`
|
||||||
|
6. executor updates task state while restart is in progress
|
||||||
|
7. frontend polls health and/or task state until recovery
|
||||||
|
|
||||||
|
Recommended command resolution examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
restart-backend -> ["./planet.sh", "restart", "-b"]
|
||||||
|
restart-frontend -> ["./planet.sh", "restart", "-f"]
|
||||||
|
restart-backend-port -> ["./planet.sh", "restart", "-b", "<port>"]
|
||||||
|
health-check -> ["./planet.sh", "health"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Polling Flow
|
||||||
|
|
||||||
|
Recommended first-phase UX:
|
||||||
|
|
||||||
|
1. user clicks `重启后端`
|
||||||
|
2. confirmation modal explains temporary unavailability
|
||||||
|
3. frontend calls `POST /api/v1/system/restart-tasks`
|
||||||
|
4. UI enters blocking restart state
|
||||||
|
5. frontend polls `/health` every `1-2s`
|
||||||
|
6. temporary request failures are treated as expected
|
||||||
|
7. after `2-3` consecutive successful health checks, frontend reloads page
|
||||||
|
|
||||||
|
Optional richer polling:
|
||||||
|
|
||||||
|
1. poll task status endpoint while backend is still reachable
|
||||||
|
2. switch to `/health` recovery polling after disconnect begins
|
||||||
|
3. refresh page after health recovery
|
||||||
|
|
||||||
|
## Frontend State Machine
|
||||||
|
|
||||||
|
- `idle`
|
||||||
|
- `confirming`
|
||||||
|
- `submitting`
|
||||||
|
- `waiting_for_shutdown`
|
||||||
|
- `waiting_for_recovery`
|
||||||
|
- `recovered`
|
||||||
|
- `failed`
|
||||||
|
- `timeout`
|
||||||
|
|
||||||
|
Suggested UI messages:
|
||||||
|
|
||||||
|
- `已发送重启指令`
|
||||||
|
- `正在停止后端服务`
|
||||||
|
- `正在等待服务恢复`
|
||||||
|
- `服务已恢复,正在刷新页面`
|
||||||
|
- `恢复超时,请手动检查服务状态`
|
||||||
|
|
||||||
|
## Phase-One Recommendation
|
||||||
|
|
||||||
|
Implement only the following in phase one:
|
||||||
|
|
||||||
|
- `restart-backend`
|
||||||
|
- `super_admin` permission gate
|
||||||
|
- task creation endpoint
|
||||||
|
- Redis-backed task state
|
||||||
|
- frontend confirmation modal
|
||||||
|
- frontend `/health` polling
|
||||||
|
- automatic page reload after recovery
|
||||||
|
|
||||||
|
Do not implement in phase one:
|
||||||
|
|
||||||
|
- full `./planet.sh restart`
|
||||||
|
- raw shell command passthrough
|
||||||
|
- arbitrary service control
|
||||||
|
- full terminal stdout streaming
|
||||||
|
- multi-action concurrent restart queueing
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. add a dedicated system-control API module under `backend/app/api/v1/`
|
||||||
|
2. add a whitelist-based action resolver for `planet.sh`
|
||||||
|
3. store restart task state in Redis
|
||||||
|
4. add detached restart-runner script execution
|
||||||
|
5. expose:
|
||||||
|
- `POST /api/v1/system/restart-tasks`
|
||||||
|
- `GET /api/v1/system/restart-tasks/{task_id}`
|
||||||
|
- optional task log endpoint
|
||||||
|
6. enforce `super_admin` permission on all restart-task endpoints
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. add a `重启后端` control on the dashboard for `super_admin`
|
||||||
|
2. show a confirmation modal before dispatch
|
||||||
|
3. after submission, switch modal into blocking restart state
|
||||||
|
4. poll `/health` until backend recovery is confirmed
|
||||||
|
5. auto-refresh page after consecutive successful health checks
|
||||||
|
6. show short stage-oriented logs instead of raw terminal streaming
|
||||||
|
|
||||||
|
### Operational Notes
|
||||||
|
|
||||||
|
1. phase one should target backend-only restart
|
||||||
|
2. frontend restart should remain out of scope initially
|
||||||
|
3. command execution must always originate from repository root
|
||||||
|
4. only fixed action names may cross the API boundary
|
||||||
|
|
||||||
|
## Validation Requirements
|
||||||
|
|
||||||
|
- Reject any action not present in the whitelist.
|
||||||
|
- If a port-bearing action is added, validate the port as an integer in
|
||||||
|
`1..65535`.
|
||||||
|
- Resolve commands from the repository root so `planet.sh` runs with a stable
|
||||||
|
working directory.
|
||||||
|
- Record the requested action, operator identity, execution start time, and
|
||||||
|
result.
|
||||||
|
|
||||||
|
## Implementation Guidance
|
||||||
|
|
||||||
|
- For UI-triggered restart flows, prefer `restart-backend` first.
|
||||||
|
- Do not rely on the current API request process to stream full restart output
|
||||||
|
after it triggers its own restart.
|
||||||
|
- Use a task record plus polling/health-check recovery flow instead of raw
|
||||||
|
terminal streaming as the primary UX.
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.21.7",
|
"version": "0.22.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.21.7",
|
"version": "0.22.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
"antd": "^5.12.5",
|
"antd": "^5.12.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.22.2",
|
"version": "0.22.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
|||||||
@@ -1243,7 +1243,7 @@ body {
|
|||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-refresh-button.ant-btn {
|
.dashboard-action-button.ant-btn {
|
||||||
height: 26px;
|
height: 26px;
|
||||||
padding-inline: 12px;
|
padding-inline: 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -1253,9 +1253,84 @@ body {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-refresh-button.ant-btn:hover,
|
.dashboard-action-button.ant-btn:hover,
|
||||||
.dashboard-refresh-button.ant-btn:focus {
|
.dashboard-action-button.ant-btn:focus {
|
||||||
border-color: #bfbfbf;
|
border-color: #bfbfbf;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: rgba(0, 0, 0, 0.88);
|
color: rgba(0, 0, 0, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-toolbar__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-toolbar__meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-toolbar__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-section__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-log {
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-log::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-restart-log::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-restart-toolbar__meta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Card, Row, Col, Statistic, Typography, Button, Tag, Spin, Space } from 'antd'
|
import { Card, Row, Col, Statistic, Typography, Button, Tag, Spin, Space, Modal, Alert, Select } from 'antd'
|
||||||
import {
|
import {
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
AlertOutlined,
|
AlertOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
|
PoweroffOutlined,
|
||||||
WifiOutlined,
|
WifiOutlined,
|
||||||
DisconnectOutlined,
|
DisconnectOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import axios from 'axios'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
@@ -29,12 +31,91 @@ interface Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RestartTask {
|
||||||
|
task_id: string
|
||||||
|
action: string
|
||||||
|
status: string
|
||||||
|
stage: string
|
||||||
|
message: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestartTaskLogs {
|
||||||
|
task_id: string
|
||||||
|
lines: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type RestartAction = 'restart-backend' | 'restart-database' | 'restart-system'
|
||||||
|
type RestartStage = 'confirming' | 'waiting_for_shutdown' | 'waiting_for_recovery' | 'recovered' | 'failed' | 'timeout'
|
||||||
|
|
||||||
|
const RESTART_ACTION_OPTIONS: Array<{ value: RestartAction; label: string; description: string; command: string }> = [
|
||||||
|
{
|
||||||
|
value: 'restart-backend',
|
||||||
|
label: '重启服务器',
|
||||||
|
description: '只重启后端服务,页面通常会短暂失联后自动恢复。',
|
||||||
|
command: './planet.sh restart -b',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'restart-database',
|
||||||
|
label: '重启数据库',
|
||||||
|
description: '重启 PostgreSQL 和 Redis 容器,前端页面保持在线。',
|
||||||
|
command: './planet.sh restart -d',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'restart-system',
|
||||||
|
label: '完全重启',
|
||||||
|
description: '重启前后端和相关服务,页面会短暂不可用,恢复后自动刷新。',
|
||||||
|
command: './planet.sh restart',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const RESTART_GUIDE_LINES: Record<RestartAction, string[]> = {
|
||||||
|
'restart-backend': [
|
||||||
|
'[ctl] preparing backend restart task',
|
||||||
|
'[ctl] handing restart to detached runner',
|
||||||
|
'[ctl] waiting for backend health recovery',
|
||||||
|
],
|
||||||
|
'restart-database': [
|
||||||
|
'[ctl] preparing database restart task',
|
||||||
|
'[ctl] restarting PostgreSQL and Redis containers',
|
||||||
|
'[ctl] waiting for containers to settle',
|
||||||
|
],
|
||||||
|
'restart-system': [
|
||||||
|
'[ctl] preparing full system restart',
|
||||||
|
'[ctl] notifying operator that frontend may disconnect',
|
||||||
|
'[ctl] stopping frontend and backend services',
|
||||||
|
'[ctl] restarting platform services',
|
||||||
|
'[ctl] polling for frontend re-entry window',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRestartConfirmMessage(action: RestartAction): string {
|
||||||
|
if (action === 'restart-database') {
|
||||||
|
return '将重启 PostgreSQL 和 Redis,页面通常保持在线,但相关请求可能短暂波动。'
|
||||||
|
}
|
||||||
|
if (action === 'restart-system') {
|
||||||
|
return '将完全重启前后端和相关服务,页面会短暂不可用,恢复后会自动刷新。'
|
||||||
|
}
|
||||||
|
return '将重启后端服务,页面会短暂不可用。'
|
||||||
|
}
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { token, clearAuth } = useAuthStore()
|
const { token, clearAuth, user } = useAuthStore()
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [wsConnected, setWsConnected] = useState(false)
|
const [wsConnected, setWsConnected] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [restartModalOpen, setRestartModalOpen] = useState(false)
|
||||||
|
const [restartSubmitting, setRestartSubmitting] = useState(false)
|
||||||
|
const [restartAction, setRestartAction] = useState<RestartAction>('restart-backend')
|
||||||
|
const [restartTaskId, setRestartTaskId] = useState<string | null>(null)
|
||||||
|
const [restartMessage, setRestartMessage] = useState(getRestartConfirmMessage('restart-backend'))
|
||||||
|
const [restartStage, setRestartStage] = useState<RestartStage>('confirming')
|
||||||
|
const [restartLogs, setRestartLogs] = useState<string[]>([])
|
||||||
|
const [restartStartedAt, setRestartStartedAt] = useState<number | null>(null)
|
||||||
|
const isSuperAdmin = user?.role === 'super_admin'
|
||||||
|
const selectedRestartAction = RESTART_ACTION_OPTIONS.find((item) => item.value === restartAction) ?? RESTART_ACTION_OPTIONS[0]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
@@ -115,6 +196,177 @@ function Dashboard() {
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openRestartModal = () => {
|
||||||
|
setRestartAction('restart-backend')
|
||||||
|
setRestartTaskId(null)
|
||||||
|
setRestartLogs([])
|
||||||
|
setRestartSubmitting(false)
|
||||||
|
setRestartStartedAt(null)
|
||||||
|
setRestartStage('confirming')
|
||||||
|
setRestartMessage(getRestartConfirmMessage('restart-backend'))
|
||||||
|
setRestartModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRestartModal = () => {
|
||||||
|
if (restartSubmitting || restartStage === 'waiting_for_shutdown' || restartStage === 'waiting_for_recovery') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRestartModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestartAction = async () => {
|
||||||
|
setRestartSubmitting(true)
|
||||||
|
setRestartLogs(RESTART_GUIDE_LINES[restartAction].slice(0, restartAction === 'restart-system' ? 3 : 1))
|
||||||
|
try {
|
||||||
|
const res = await axios.post<RestartTask>('/api/v1/system/restart-tasks', { action: restartAction })
|
||||||
|
setRestartTaskId(res.data.task_id)
|
||||||
|
setRestartStartedAt(Date.now())
|
||||||
|
setRestartStage('waiting_for_shutdown')
|
||||||
|
setRestartMessage(
|
||||||
|
restartAction === 'restart-system'
|
||||||
|
? '已发送完全重启指令,页面可能暂时失联,恢复后会自动刷新。'
|
||||||
|
: '已发送重启指令,正在等待服务进入重启流程。'
|
||||||
|
)
|
||||||
|
setRestartLogs((current) => [...current, `任务已创建: ${res.data.task_id}`])
|
||||||
|
} catch (restartError: unknown) {
|
||||||
|
const err = restartError as { response?: { data?: { detail?: string } } }
|
||||||
|
setRestartStage('failed')
|
||||||
|
setRestartMessage(err.response?.data?.detail || '提交重启任务失败')
|
||||||
|
setRestartLogs((current) => [...current, '提交重启任务失败'])
|
||||||
|
} finally {
|
||||||
|
setRestartSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!restartModalOpen || restartStage !== 'confirming') return
|
||||||
|
setRestartMessage(getRestartConfirmMessage(restartAction))
|
||||||
|
}, [restartAction, restartModalOpen, restartStage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!restartModalOpen || !restartTaskId || restartStartedAt === null) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let sawUnhealthy = false
|
||||||
|
let healthyStreak = 0
|
||||||
|
let frontendHealthyStreak = 0
|
||||||
|
let pollTimer: number | null = null
|
||||||
|
|
||||||
|
const appendLog = (line: string) => {
|
||||||
|
setRestartLogs((current) => (current[current.length - 1] === line ? current : [...current, line].slice(-8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const elapsed = Date.now() - restartStartedAt
|
||||||
|
if (elapsed > 90_000) {
|
||||||
|
setRestartStage('timeout')
|
||||||
|
setRestartMessage('恢复超时,请手动检查后端服务状态。')
|
||||||
|
appendLog('恢复超时,请手动检查服务状态')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskRes = await axios.get<RestartTask>(`/api/v1/system/restart-tasks/${restartTaskId}`, { timeout: 1500 })
|
||||||
|
const task = taskRes.data
|
||||||
|
if (!cancelled && task?.message) {
|
||||||
|
setRestartMessage(task.message)
|
||||||
|
}
|
||||||
|
if (!cancelled && restartAction !== 'restart-system') {
|
||||||
|
const logsRes = await axios.get<RestartTaskLogs>(`/api/v1/system/restart-tasks/${restartTaskId}/logs`, { timeout: 1500 })
|
||||||
|
if (logsRes.data.lines.length > 0) {
|
||||||
|
setRestartLogs(logsRes.data.lines.slice(-8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled && typeof task?.stage === 'string') {
|
||||||
|
if (task.stage === 'healthy') {
|
||||||
|
setRestartStage('recovered')
|
||||||
|
setRestartMessage('服务已恢复,正在刷新页面。')
|
||||||
|
appendLog('后端已恢复,正在刷新页面')
|
||||||
|
window.setTimeout(() => window.location.reload(), 600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (task.status === 'failed' || task.status === 'timeout') {
|
||||||
|
setRestartStage(task.status === 'timeout' ? 'timeout' : 'failed')
|
||||||
|
setRestartMessage(task.message || '重启任务失败')
|
||||||
|
appendLog(task.message || '重启任务失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Backend may be temporarily down during restart; handled by health polling below.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restartAction === 'restart-system') {
|
||||||
|
try {
|
||||||
|
const rootRes = await fetch(`/?restart_probe=${Date.now()}`, { cache: 'no-store' })
|
||||||
|
if (rootRes.ok) {
|
||||||
|
frontendHealthyStreak += 1
|
||||||
|
if (sawUnhealthy && frontendHealthyStreak >= 2) {
|
||||||
|
setRestartStage('recovered')
|
||||||
|
setRestartMessage('系统已恢复,正在刷新页面。')
|
||||||
|
appendLog('[ctl] frontend entrypoint reachable again')
|
||||||
|
window.setTimeout(() => window.location.reload(), 600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sawUnhealthy = true
|
||||||
|
frontendHealthyStreak = 0
|
||||||
|
setRestartStage('waiting_for_recovery')
|
||||||
|
setRestartMessage('系统正在完全重启,正在等待前端恢复访问。')
|
||||||
|
appendLog('[ctl] frontend is temporarily unavailable')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sawUnhealthy = true
|
||||||
|
frontendHealthyStreak = 0
|
||||||
|
setRestartStage('waiting_for_recovery')
|
||||||
|
setRestartMessage('系统正在完全重启,正在等待前端恢复访问。')
|
||||||
|
appendLog('[ctl] frontend is temporarily unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
pollTimer = window.setTimeout(poll, 1500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const healthRes = await fetch('/health', { cache: 'no-store' })
|
||||||
|
if (healthRes.ok) {
|
||||||
|
healthyStreak += 1
|
||||||
|
if (sawUnhealthy && healthyStreak >= 2) {
|
||||||
|
setRestartStage('recovered')
|
||||||
|
setRestartMessage('服务已恢复,正在刷新页面。')
|
||||||
|
appendLog('健康检查已恢复,正在刷新页面')
|
||||||
|
window.setTimeout(() => window.location.reload(), 600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sawUnhealthy = true
|
||||||
|
healthyStreak = 0
|
||||||
|
setRestartStage('waiting_for_recovery')
|
||||||
|
setRestartMessage('后端已停止响应,正在等待服务恢复。')
|
||||||
|
appendLog('检测到后端已停止响应')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sawUnhealthy = true
|
||||||
|
healthyStreak = 0
|
||||||
|
setRestartStage('waiting_for_recovery')
|
||||||
|
setRestartMessage('后端已停止响应,正在等待服务恢复。')
|
||||||
|
appendLog('检测到后端已停止响应')
|
||||||
|
}
|
||||||
|
|
||||||
|
pollTimer = window.setTimeout(poll, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
pollTimer = window.setTimeout(poll, 1200)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
window.clearTimeout(pollTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [restartAction, restartModalOpen, restartStartedAt, restartTaskId])
|
||||||
|
|
||||||
if (loading && !stats) {
|
if (loading && !stats) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
@@ -137,7 +389,17 @@ function Dashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<Tag className="dashboard-status-tag" icon={<DisconnectOutlined />} color="default">离线</Tag>
|
<Tag className="dashboard-status-tag" icon={<DisconnectOutlined />} color="default">离线</Tag>
|
||||||
)}
|
)}
|
||||||
<Button className="dashboard-refresh-button" icon={<ReloadOutlined />} onClick={handleRetry}>刷新</Button>
|
{isSuperAdmin ? (
|
||||||
|
<Button
|
||||||
|
className="dashboard-action-button dashboard-restart-button"
|
||||||
|
icon={<PoweroffOutlined />}
|
||||||
|
danger
|
||||||
|
onClick={openRestartModal}
|
||||||
|
>
|
||||||
|
重启
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button className="dashboard-action-button dashboard-refresh-button" icon={<ReloadOutlined />} onClick={handleRetry}>刷新</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,6 +471,84 @@ function Dashboard() {
|
|||||||
{wsConnected && <Tag className="dashboard-status-tag" color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
{wsConnected && <Tag className="dashboard-status-tag" color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="重启服务"
|
||||||
|
open={restartModalOpen}
|
||||||
|
onCancel={closeRestartModal}
|
||||||
|
maskClosable={false}
|
||||||
|
closable={!restartSubmitting && restartStage !== 'waiting_for_shutdown' && restartStage !== 'waiting_for_recovery'}
|
||||||
|
footer={[
|
||||||
|
<Button
|
||||||
|
key="close"
|
||||||
|
onClick={closeRestartModal}
|
||||||
|
disabled={restartSubmitting || restartStage === 'waiting_for_shutdown' || restartStage === 'waiting_for_recovery'}
|
||||||
|
>
|
||||||
|
{restartStage === 'confirming' ? '取消' : '关闭'}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="submit"
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
loading={restartSubmitting}
|
||||||
|
disabled={restartSubmitting || restartStage !== 'confirming'}
|
||||||
|
onClick={handleRestartAction}
|
||||||
|
>
|
||||||
|
重启
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="dashboard-restart-modal">
|
||||||
|
<div className="dashboard-restart-toolbar">
|
||||||
|
<div className="dashboard-restart-toolbar__field">
|
||||||
|
<Text className="dashboard-restart-section__label">重启动作</Text>
|
||||||
|
<Select
|
||||||
|
value={restartAction}
|
||||||
|
onChange={(value) => setRestartAction(value)}
|
||||||
|
disabled={restartSubmitting || restartStage !== 'confirming'}
|
||||||
|
options={RESTART_ACTION_OPTIONS.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: item.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-restart-toolbar__meta">
|
||||||
|
<div className="dashboard-restart-toolbar__item">
|
||||||
|
<Text type="secondary">执行命令</Text>
|
||||||
|
<Text code>{selectedRestartAction.command}</Text>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-restart-toolbar__item">
|
||||||
|
<Text type="secondary">任务 ID</Text>
|
||||||
|
<Text>{restartTaskId || '等待创建'}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-restart-section">
|
||||||
|
<Text className="dashboard-restart-section__label">状态信息</Text>
|
||||||
|
<Alert
|
||||||
|
type={
|
||||||
|
restartStage === 'failed' || restartStage === 'timeout'
|
||||||
|
? 'error'
|
||||||
|
: restartStage === 'recovered'
|
||||||
|
? 'success'
|
||||||
|
: 'info'
|
||||||
|
}
|
||||||
|
message={restartMessage}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-restart-section">
|
||||||
|
<Text className="dashboard-restart-section__label">终端输出</Text>
|
||||||
|
<div className="dashboard-restart-log">
|
||||||
|
{restartLogs.length > 0 ? restartLogs.map((line, index) => (
|
||||||
|
<div key={`${line}-${index}`}>{line}</div>
|
||||||
|
)) : <div>等待操作</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/health': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fs: {
|
fs: {
|
||||||
allow: ['..'],
|
allow: ['..'],
|
||||||
|
|||||||
22
planet.sh
22
planet.sh
@@ -168,6 +168,7 @@ parse_service_args() {
|
|||||||
FRONTEND_PORT="$DEFAULT_FRONTEND_PORT"
|
FRONTEND_PORT="$DEFAULT_FRONTEND_PORT"
|
||||||
BACKEND_PORT_REQUESTED=0
|
BACKEND_PORT_REQUESTED=0
|
||||||
FRONTEND_PORT_REQUESTED=0
|
FRONTEND_PORT_REQUESTED=0
|
||||||
|
DATABASE_REQUESTED=0
|
||||||
|
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -189,6 +190,10 @@ parse_service_args() {
|
|||||||
shift 1
|
shift 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
-d|--database)
|
||||||
|
DATABASE_REQUESTED=1
|
||||||
|
shift 1
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo -e "${RED}❌ 未知参数: $1${NC}"
|
echo -e "${RED}❌ 未知参数: $1${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -219,6 +224,12 @@ stop_frontend_service() {
|
|||||||
pkill -f "bun run dev" 2>/dev/null || true
|
pkill -f "bun run dev" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restart_database_service() {
|
||||||
|
echo -e "${BLUE}🗄️ 重启数据库...${NC}"
|
||||||
|
docker restart planet_postgres planet_redis 2>/dev/null || docker-compose up -d postgres redis
|
||||||
|
sleep 3
|
||||||
|
}
|
||||||
|
|
||||||
start_backend_service() {
|
start_backend_service() {
|
||||||
local backend_port="$1"
|
local backend_port="$1"
|
||||||
local backend_port_requested="$2"
|
local backend_port_requested="$2"
|
||||||
@@ -389,7 +400,7 @@ restart() {
|
|||||||
parse_service_args "$@"
|
parse_service_args "$@"
|
||||||
cleanup_exit_containers
|
cleanup_exit_containers
|
||||||
|
|
||||||
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ]; then
|
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
|
||||||
stop
|
stop
|
||||||
sleep 1
|
sleep 1
|
||||||
start
|
start
|
||||||
@@ -398,6 +409,10 @@ restart() {
|
|||||||
|
|
||||||
echo -e "${YELLOW}🔄 按需重启服务...${NC}"
|
echo -e "${YELLOW}🔄 按需重启服务...${NC}"
|
||||||
|
|
||||||
|
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||||||
|
restart_database_service
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
||||||
stop_backend_service
|
stop_backend_service
|
||||||
sleep 1
|
sleep 1
|
||||||
@@ -412,6 +427,9 @@ restart() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}✅ 重启完成!${NC}"
|
echo -e "${GREEN}✅ 重启完成!${NC}"
|
||||||
|
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||||||
|
echo " 数据库: planet_postgres, planet_redis"
|
||||||
|
fi
|
||||||
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
||||||
echo " 后端: http://localhost:${BACKEND_PORT}"
|
echo " 后端: http://localhost:${BACKEND_PORT}"
|
||||||
fi
|
fi
|
||||||
@@ -486,7 +504,7 @@ case "$1" in
|
|||||||
echo "命令:"
|
echo "命令:"
|
||||||
echo " start 启动服务,可选: -b <后端端口> -f <前端端口>"
|
echo " start 启动服务,可选: -b <后端端口> -f <前端端口>"
|
||||||
echo " stop 停止服务"
|
echo " stop 停止服务"
|
||||||
echo " restart 重启服务,可选: -b [后端端口] -f [前端端口]"
|
echo " restart 重启服务,可选: -b [后端端口] -f [前端端口] -d"
|
||||||
echo " createuser 交互创建用户"
|
echo " createuser 交互创建用户"
|
||||||
echo " health 检查健康状态"
|
echo " health 检查健康状态"
|
||||||
echo " log 查看日志"
|
echo " log 查看日志"
|
||||||
|
|||||||
Reference in New Issue
Block a user