codex/aiprovider-foundation #4
@@ -12,6 +12,7 @@ from app.api.v1 import (
|
||||
collected_data,
|
||||
visualization,
|
||||
bgp,
|
||||
system_control,
|
||||
)
|
||||
|
||||
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(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||
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(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`
|
||||
- `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
|
||||
|
||||
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",
|
||||
"version": "0.21.7",
|
||||
"version": "0.22.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planet-frontend",
|
||||
"version": "0.21.7",
|
||||
"version": "0.22.3",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"antd": "^5.12.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.22.2",
|
||||
"version": "0.22.3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
|
||||
@@ -1243,7 +1243,7 @@ body {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.dashboard-refresh-button.ant-btn {
|
||||
.dashboard-action-button.ant-btn {
|
||||
height: 26px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 999px;
|
||||
@@ -1253,9 +1253,84 @@ body {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dashboard-refresh-button.ant-btn:hover,
|
||||
.dashboard-refresh-button.ant-btn:focus {
|
||||
.dashboard-action-button.ant-btn:hover,
|
||||
.dashboard-action-button.ant-btn:focus {
|
||||
border-color: #bfbfbf;
|
||||
background: #ffffff;
|
||||
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 { 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 {
|
||||
DatabaseOutlined,
|
||||
BarChartOutlined,
|
||||
AlertOutlined,
|
||||
GlobalOutlined,
|
||||
PoweroffOutlined,
|
||||
WifiOutlined,
|
||||
DisconnectOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
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() {
|
||||
const { token, clearAuth } = useAuthStore()
|
||||
const { token, clearAuth, user } = useAuthStore()
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [wsConnected, setWsConnected] = useState(false)
|
||||
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(() => {
|
||||
if (!token) return
|
||||
@@ -115,6 +196,177 @@ function Dashboard() {
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -209,6 +471,84 @@ function Dashboard() {
|
||||
{wsConnected && <Tag className="dashboard-status-tag" color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
||||
</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>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -52,6 +52,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
fs: {
|
||||
allow: ['..'],
|
||||
|
||||
22
planet.sh
22
planet.sh
@@ -168,6 +168,7 @@ parse_service_args() {
|
||||
FRONTEND_PORT="$DEFAULT_FRONTEND_PORT"
|
||||
BACKEND_PORT_REQUESTED=0
|
||||
FRONTEND_PORT_REQUESTED=0
|
||||
DATABASE_REQUESTED=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -189,6 +190,10 @@ parse_service_args() {
|
||||
shift 1
|
||||
fi
|
||||
;;
|
||||
-d|--database)
|
||||
DATABASE_REQUESTED=1
|
||||
shift 1
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}❌ 未知参数: $1${NC}"
|
||||
exit 1
|
||||
@@ -219,6 +224,12 @@ stop_frontend_service() {
|
||||
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() {
|
||||
local backend_port="$1"
|
||||
local backend_port_requested="$2"
|
||||
@@ -389,7 +400,7 @@ restart() {
|
||||
parse_service_args "$@"
|
||||
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
|
||||
sleep 1
|
||||
start
|
||||
@@ -398,6 +409,10 @@ restart() {
|
||||
|
||||
echo -e "${YELLOW}🔄 按需重启服务...${NC}"
|
||||
|
||||
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||||
restart_database_service
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
||||
stop_backend_service
|
||||
sleep 1
|
||||
@@ -412,6 +427,9 @@ restart() {
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ 重启完成!${NC}"
|
||||
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||||
echo " 数据库: planet_postgres, planet_redis"
|
||||
fi
|
||||
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
||||
echo " 后端: http://localhost:${BACKEND_PORT}"
|
||||
fi
|
||||
@@ -486,7 +504,7 @@ case "$1" in
|
||||
echo "命令:"
|
||||
echo " start 启动服务,可选: -b <后端端口> -f <前端端口>"
|
||||
echo " stop 停止服务"
|
||||
echo " restart 重启服务,可选: -b [后端端口] -f [前端端口]"
|
||||
echo " restart 重启服务,可选: -b [后端端口] -f [前端端口] -d"
|
||||
echo " createuser 交互创建用户"
|
||||
echo " health 检查健康状态"
|
||||
echo " log 查看日志"
|
||||
|
||||
Reference in New Issue
Block a user