diff --git a/VERSION b/VERSION index faa5fb26..d90746a7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.2 +0.22.3 diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 3a7f8c39..b7611057 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -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"]) diff --git a/backend/app/api/v1/system_control.py b/backend/app/api/v1/system_control.py new file mode 100644 index 00000000..21c81208 --- /dev/null +++ b/backend/app/api/v1/system_control.py @@ -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)} diff --git a/backend/app/services/system_control.py b/backend/app/services/system_control.py new file mode 100644 index 00000000..226b5ba9 --- /dev/null +++ b/backend/app/services/system_control.py @@ -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" diff --git a/backend/scripts/system_restart_runner.py b/backend/scripts/system_restart_runner.py new file mode 100644 index 00000000..c2bb5c8c --- /dev/null +++ b/backend/scripts/system_restart_runner.py @@ -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()) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a349043d..82797f18 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/system-service-control.md b/docs/system-service-control.md new file mode 100644 index 00000000..12bf6e56 --- /dev/null +++ b/docs/system-service-control.md @@ -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 must be backend-validated before execution. | +| `restart-frontend-port` | Restart frontend on a specific port | `./planet.sh restart -f ` | 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", ""] +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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 25d317e7..5e3f9da3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index f6563610..53e392e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.22.2", + "version": "0.22.3", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/src/index.css b/frontend/src/index.css index d58896a4..5a89c1df 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/pages/Dashboard/Dashboard.tsx b/frontend/src/pages/Dashboard/Dashboard.tsx index 07df65b5..b7799893 100644 --- a/frontend/src/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/pages/Dashboard/Dashboard.tsx @@ -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 = { + '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(null) const [loading, setLoading] = useState(true) const [wsConnected, setWsConnected] = useState(false) const [error, setError] = useState(null) + const [restartModalOpen, setRestartModalOpen] = useState(false) + const [restartSubmitting, setRestartSubmitting] = useState(false) + const [restartAction, setRestartAction] = useState('restart-backend') + const [restartTaskId, setRestartTaskId] = useState(null) + const [restartMessage, setRestartMessage] = useState(getRestartConfirmMessage('restart-backend')) + const [restartStage, setRestartStage] = useState('confirming') + const [restartLogs, setRestartLogs] = useState([]) + const [restartStartedAt, setRestartStartedAt] = useState(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('/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(`/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(`/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 (
@@ -137,7 +389,17 @@ function Dashboard() { ) : ( } color="default">离线 )} - + {isSuperAdmin ? ( + + ) : null} +
@@ -209,6 +471,84 @@ function Dashboard() { {wsConnected && 实时同步中} )} + + + {restartStage === 'confirming' ? '取消' : '关闭'} + , + , + ]} + > +
+
+
+ 重启动作 +