fix: add controlled service restart console

This commit is contained in:
linkong
2026-03-31 17:56:31 +08:00
parent e384318b50
commit e903723877
14 changed files with 1350 additions and 13 deletions

View File

@@ -1 +1 @@
0.22.2 0.22.3

View File

@@ -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"])

View 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)}

View 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"

View 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())

View File

@@ -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

View 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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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>
) )

View File

@@ -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: ['..'],

View File

@@ -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 查看日志"

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]] [[package]]
name = "planet" name = "planet"
version = "0.21.9" version = "0.22.2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },