2653 lines
84 KiB
Bash
Executable File
2653 lines
84 KiB
Bash
Executable File
#!/usr/bin/env zsh
|
||
|
||
set -e
|
||
|
||
SCRIPT_PATH="${(%):-%N}"
|
||
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||
cd "$SCRIPT_DIR"
|
||
|
||
load_user_zshrc_exported_env() {
|
||
local load_mode="${PLANET_LOAD_ZSHRC_ENV:-1}"
|
||
[ "$load_mode" != "0" ] || return 0
|
||
[ "$load_mode" != "false" ] || return 0
|
||
[ -f "$HOME/.zshrc" ] || return 0
|
||
|
||
local env_line env_name env_value
|
||
if [ "$load_mode" = "source" ]; then
|
||
command -v zsh >/dev/null 2>&1 || return 0
|
||
while IFS= read -r env_line; do
|
||
env_name="${env_line%%=*}"
|
||
env_value="${env_line#*=}"
|
||
case "$env_name" in
|
||
AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy)
|
||
export "${env_name}=${env_value}"
|
||
;;
|
||
esac
|
||
done < <(zsh -fc 'source ~/.zshrc >/dev/null 2>&1; env' 2>/dev/null || true)
|
||
return 0
|
||
fi
|
||
|
||
while IFS= read -r env_line; do
|
||
env_line="$(printf "%s" "$env_line" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')"
|
||
case "$env_line" in
|
||
""|\#*) continue ;;
|
||
export\ *) env_line="${env_line#export }" ;;
|
||
esac
|
||
env_name="${env_line%%=*}"
|
||
env_value="${env_line#*=}"
|
||
[ "$env_name" != "$env_value" ] || continue
|
||
env_name="$(printf "%s" "$env_name" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')"
|
||
env_value="$(printf "%s" "$env_value" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//;s/^"//;s/"$//;s/^'\''//;s/'\''$//')"
|
||
case "$env_name" in
|
||
AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy)
|
||
export "${env_name}=${env_value}"
|
||
;;
|
||
esac
|
||
done < "$HOME/.zshrc"
|
||
}
|
||
|
||
load_user_zshrc_exported_env
|
||
|
||
RED='\033[38;5;203m'
|
||
GREEN='\033[38;5;114m'
|
||
YELLOW='\033[38;5;221m'
|
||
BLUE='\033[38;5;111m'
|
||
CYAN='\033[38;5;117m'
|
||
WHITE='\033[1;37m'
|
||
DIM='\033[2m'
|
||
NC='\033[0m'
|
||
WAIT_SPINNER_FRAMES=(
|
||
'⠉⠁'
|
||
'⠈⠁'
|
||
'⠈⠉'
|
||
' ⠙'
|
||
' ⠸'
|
||
' ⢰'
|
||
' ⣠'
|
||
'⢀⣀'
|
||
'⣀⡀'
|
||
'⣄ '
|
||
'⡆ '
|
||
'⠇ '
|
||
'⠋ '
|
||
'⠋⠁'
|
||
)
|
||
WAIT_SPINNER_STEP=0
|
||
WAIT_SPINNER_TICKS_PER_SECOND=8
|
||
WAIT_SPINNER_DETAIL=""
|
||
WAIT_SPINNER_MESSAGE=""
|
||
WAIT_SESSION_ACTIVE=0
|
||
VERBOSE=0
|
||
WAIT_VERBOSE_LOG_FILE=""
|
||
WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}"
|
||
WAIT_VERBOSE_TAIL_LINE_COUNT="${WAIT_VERBOSE_TAIL_LINE_COUNT:-40}"
|
||
HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-0.2}"
|
||
|
||
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
|
||
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}"
|
||
BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}"
|
||
DEPENDENCY_INSTALL_MAX_RETRIES="${DEPENDENCY_INSTALL_MAX_RETRIES:-3}"
|
||
DEPENDENCY_INSTALL_RETRY_INTERVAL="${DEPENDENCY_INSTALL_RETRY_INTERVAL:-5}"
|
||
AI_PROVIDER_HEALTH_CHECK_ATTEMPTS="${AI_PROVIDER_HEALTH_CHECK_ATTEMPTS:-10}"
|
||
AI_PROVIDER_HEALTH_CHECK_INTERVAL="${AI_PROVIDER_HEALTH_CHECK_INTERVAL:-2}"
|
||
AI_PROVIDER_START_MAX_RETRIES="${AI_PROVIDER_START_MAX_RETRIES:-3}"
|
||
AI_PROVIDER_RETRY_INTERVAL="${AI_PROVIDER_RETRY_INTERVAL:-5}"
|
||
DATABASE_START_MAX_RETRIES="${DATABASE_START_MAX_RETRIES:-3}"
|
||
DATABASE_RETRY_INTERVAL="${DATABASE_RETRY_INTERVAL:-5}"
|
||
FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}"
|
||
FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}"
|
||
FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}"
|
||
PORT_RELEASE_ATTEMPTS="${PORT_RELEASE_ATTEMPTS:-15}"
|
||
PORT_RELEASE_INTERVAL="${PORT_RELEASE_INTERVAL:-0.2}"
|
||
PORT_PRESTART_RETRIES="${PORT_PRESTART_RETRIES:-3}"
|
||
PORT_PRESTART_RETRY_INTERVAL="${PORT_PRESTART_RETRY_INTERVAL:-2}"
|
||
DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}"
|
||
DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}"
|
||
DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
|
||
DEFAULT_MOTION_AGENT_PORT="${DEFAULT_MOTION_AGENT_PORT:-8765}"
|
||
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
|
||
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
|
||
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
|
||
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
|
||
MOTION_AGENT_PID_FILE="/tmp/planet_motion_agent.pid"
|
||
MOTION_AGENT_LOG_FILE="/tmp/planet_motion_agent.log"
|
||
AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256"
|
||
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
|
||
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
|
||
AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}"
|
||
PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-/tmp/planet_aiprovider_runtime.env}"
|
||
AI_PROVIDER_RECREATE_REQUIRED=0
|
||
AI_PROVIDER_RUNTIME_ENV_NAMES=(
|
||
SERVICE_NAME
|
||
SERVICE_VERSION
|
||
AI_PROVIDER
|
||
AI_PROVIDER_API
|
||
AI_BASE_URL
|
||
AI_API_KEY
|
||
AI_MODEL
|
||
AI_TIMEOUT_SECONDS
|
||
AI_HTTP_RETRY_ATTEMPTS
|
||
AI_MAX_TOKENS
|
||
AI_ANTHROPIC_VERSION
|
||
AI_ANALYSIS_SYSTEM_PROMPT
|
||
AI_PROVIDER_SERVICE_TOKEN
|
||
)
|
||
|
||
prepend_path_once() {
|
||
local path_entry="$1"
|
||
[ -n "$path_entry" ] || return 0
|
||
[ -d "$path_entry" ] || return 0
|
||
|
||
case ":$PATH:" in
|
||
*":$path_entry:"*) ;;
|
||
*) export PATH="$path_entry:$PATH" ;;
|
||
esac
|
||
}
|
||
|
||
ensure_local_runtime_bins_on_path() {
|
||
prepend_path_once "$HOME/.local/bin"
|
||
prepend_path_once "$HOME/.bun/bin"
|
||
}
|
||
|
||
ensure_local_runtime_bins_on_path
|
||
|
||
# Shell / Docker helpers
|
||
compose_available() {
|
||
docker compose version >/dev/null 2>&1
|
||
}
|
||
|
||
compose_v1_available() {
|
||
command -v docker-compose >/dev/null 2>&1
|
||
}
|
||
|
||
report_missing_compose() {
|
||
clear_wait_spinner
|
||
log_error "未检测到可用的 Docker Compose"
|
||
log_note '优先使用 `docker compose`'
|
||
log_note "如果当前机器仍只装有旧版 `docker-compose`,也请确认该命令可执行"
|
||
log_note "推荐安装 Docker Compose 插件:"
|
||
log_note ' mkdir -p ~/.docker/cli-plugins'
|
||
log_note ' curl -SL https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose'
|
||
log_note ' chmod +x ~/.docker/cli-plugins/docker-compose'
|
||
log_note ' docker compose version'
|
||
return 1
|
||
}
|
||
|
||
buildx_version() {
|
||
docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//'
|
||
}
|
||
|
||
buildx_meets_minimum() {
|
||
local current_version="$1"
|
||
local required_version="$2"
|
||
|
||
[ -n "$current_version" ] || return 1
|
||
[ -n "$required_version" ] || return 1
|
||
|
||
if [ "$(printf '%s\n%s\n' "$required_version" "$current_version" | sort -V | head -n 1)" = "$required_version" ]; then
|
||
return 0
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
log_buildx_diagnostics_if_needed() {
|
||
local current_buildx_version=""
|
||
local required_buildx_version="0.17.0"
|
||
|
||
current_buildx_version="$(buildx_version)"
|
||
|
||
if [ -z "$current_buildx_version" ]; then
|
||
log_note "未检测到 docker buildx,AI Provider 镜像构建依赖 buildx。"
|
||
log_note "请先执行: docker buildx version"
|
||
return 0
|
||
fi
|
||
|
||
if ! buildx_meets_minimum "$current_buildx_version" "$required_buildx_version"; then
|
||
log_note "检测到 docker buildx 当前版本为 v${current_buildx_version},但 compose build 需要 v${required_buildx_version} 或更高。"
|
||
log_note "可先升级 buildx 插件,再重新执行 ./planet.sh start"
|
||
log_note "请先执行: docker buildx version"
|
||
fi
|
||
}
|
||
|
||
compose_up() {
|
||
local args=("$@")
|
||
local arg_text="$*"
|
||
|
||
if compose_available; then
|
||
set_wait_detail "正在用 docker compose 执行: ${arg_text}"
|
||
if docker compose "${args[@]}"; then
|
||
return 0
|
||
fi
|
||
log_warn "docker compose 执行失败,回退到 docker-compose v1"
|
||
fi
|
||
|
||
if compose_v1_available; then
|
||
set_wait_detail "正在用 docker-compose v1 执行: ${arg_text}"
|
||
if docker-compose "${args[@]}"; then
|
||
return 0
|
||
fi
|
||
clear_wait_spinner
|
||
log_error "docker compose 与 docker-compose v1 均执行失败"
|
||
log_note "最后尝试的命令: docker-compose ${arg_text}"
|
||
return 1
|
||
fi
|
||
|
||
report_missing_compose
|
||
}
|
||
|
||
compose_supports_build() {
|
||
if compose_available; then
|
||
docker compose build --help >/dev/null 2>&1 && return 0
|
||
fi
|
||
if compose_v1_available; then
|
||
docker-compose build --help >/dev/null 2>&1 && return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# Terminal rendering helpers
|
||
render_wait_spinner() {
|
||
local message="$1"
|
||
local step="${2:-1}"
|
||
local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} ))
|
||
local frame="${WAIT_SPINNER_FRAMES[$frame_index]}"
|
||
local detail="${WAIT_SPINNER_DETAIL:- }"
|
||
local verbose_line=""
|
||
local verbose_lines=()
|
||
local line_count=0
|
||
local cursor_up=1
|
||
|
||
if [ "$VERBOSE" -eq 1 ] && [ -n "$WAIT_VERBOSE_LOG_FILE" ] && [ -f "$WAIT_VERBOSE_LOG_FILE" ]; then
|
||
while IFS= read -r verbose_line; do
|
||
verbose_line="$(sanitize_wait_detail "$verbose_line")"
|
||
[ -n "$verbose_line" ] || continue
|
||
verbose_lines+=("$verbose_line")
|
||
done < <(tail -n "$WAIT_VERBOSE_TAIL_LINE_COUNT" "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null)
|
||
|
||
while [ "${#verbose_lines[@]}" -gt "$WAIT_VERBOSE_LINE_COUNT" ]; do
|
||
verbose_lines=("${verbose_lines[@]:1}")
|
||
done
|
||
fi
|
||
|
||
printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&2
|
||
|
||
if [ "$VERBOSE" -eq 1 ]; then
|
||
line_count=1
|
||
while [ "$line_count" -le "$WAIT_VERBOSE_LINE_COUNT" ]; do
|
||
verbose_line="${verbose_lines[$line_count]:- }"
|
||
printf "\n${DIM} %s${NC}\033[K" "$verbose_line" >&2
|
||
line_count=$((line_count + 1))
|
||
done
|
||
cursor_up=$((WAIT_VERBOSE_LINE_COUNT + 1))
|
||
fi
|
||
|
||
printf "\033[%sA\r" "$cursor_up" >&2
|
||
}
|
||
|
||
animate_wait_spinner() {
|
||
local message="$1"
|
||
local duration="${2:-1}"
|
||
local ticks=$(( duration * WAIT_SPINNER_TICKS_PER_SECOND ))
|
||
local tick=0
|
||
|
||
if [ "$ticks" -lt 1 ]; then
|
||
ticks=1
|
||
fi
|
||
|
||
while [ "$tick" -lt "$ticks" ]; do
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
render_wait_spinner "$message" "$WAIT_SPINNER_STEP"
|
||
sleep 0.125
|
||
tick=$((tick + 1))
|
||
done
|
||
}
|
||
|
||
clear_wait_spinner() {
|
||
local line_count=0
|
||
local clear_verbose_lines=0
|
||
|
||
if [ "$VERBOSE" -eq 1 ] && { [ "$WAIT_SESSION_ACTIVE" -eq 1 ] || [ -n "$WAIT_VERBOSE_LOG_FILE" ]; }; then
|
||
clear_verbose_lines=1
|
||
fi
|
||
|
||
printf "\r\033[K" >&2
|
||
line_count=0
|
||
while [ "$line_count" -lt 1 ]; do
|
||
printf "\n\033[K" >&2
|
||
line_count=$((line_count + 1))
|
||
done
|
||
|
||
if [ "$clear_verbose_lines" -eq 1 ]; then
|
||
line_count=0
|
||
while [ "$line_count" -lt "$WAIT_VERBOSE_LINE_COUNT" ]; do
|
||
printf "\n\033[K" >&2
|
||
line_count=$((line_count + 1))
|
||
done
|
||
fi
|
||
|
||
printf "\033[%sA\r" "$((clear_verbose_lines == 1 ? WAIT_VERBOSE_LINE_COUNT + 1 : 1))" >&2
|
||
}
|
||
|
||
start_wait_session() {
|
||
WAIT_SESSION_ACTIVE=1
|
||
WAIT_SPINNER_MESSAGE="$1"
|
||
WAIT_SPINNER_DETAIL=""
|
||
WAIT_SPINNER_STEP=1
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
}
|
||
|
||
resume_wait_session() {
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
fi
|
||
}
|
||
|
||
stop_wait_session() {
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
|
||
clear_wait_spinner
|
||
fi
|
||
WAIT_SESSION_ACTIVE=0
|
||
reset_wait_spinner_state
|
||
}
|
||
|
||
close_wait_session_context() {
|
||
local owns_wait_session="$1"
|
||
|
||
if [ "$owns_wait_session" -eq 1 ]; then
|
||
stop_wait_session
|
||
else
|
||
reset_wait_spinner_state
|
||
fi
|
||
}
|
||
|
||
reset_wait_spinner_state() {
|
||
WAIT_SPINNER_DETAIL=""
|
||
WAIT_SPINNER_MESSAGE=""
|
||
WAIT_VERBOSE_LOG_FILE=""
|
||
}
|
||
|
||
sanitize_wait_detail() {
|
||
local line="$1"
|
||
|
||
line="$(printf "%s" "$line" | tr '\t' ' ' | tr -d '\r')"
|
||
line="$(printf "%s" "$line" | sed -E 's/\x1B\[[0-9;]*[[:alpha:]]//g')"
|
||
line="$(printf "%s" "$line" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')"
|
||
|
||
if [ "${#line}" -gt 140 ]; then
|
||
line="${line:0:137}..."
|
||
fi
|
||
|
||
printf "%s" "$line"
|
||
}
|
||
|
||
set_wait_detail() {
|
||
WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")"
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
fi
|
||
}
|
||
|
||
run_command_with_spinner() {
|
||
local message="$1"
|
||
shift
|
||
local verbose_log_file=""
|
||
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
|
||
set_wait_detail "$message"
|
||
fi
|
||
|
||
if [ "$VERBOSE" -eq 1 ]; then
|
||
verbose_log_file="$(mktemp /tmp/planet_verbose.XXXXXX.log)"
|
||
WAIT_VERBOSE_LOG_FILE="$verbose_log_file"
|
||
"$@" > "$verbose_log_file" 2>&1 &
|
||
else
|
||
"$@" &
|
||
fi
|
||
local command_pid=$!
|
||
local exit_code=0
|
||
|
||
while kill -0 "$command_pid" 2>/dev/null; do
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
else
|
||
render_wait_spinner "$message" "$WAIT_SPINNER_STEP"
|
||
fi
|
||
sleep 0.125
|
||
done
|
||
|
||
wait "$command_pid" || exit_code=$?
|
||
if [ "$VERBOSE" -eq 1 ] && [ -n "$verbose_log_file" ]; then
|
||
WAIT_VERBOSE_LOG_FILE="$verbose_log_file"
|
||
fi
|
||
return "$exit_code"
|
||
}
|
||
|
||
resolve_bun_from_shell_configs() {
|
||
local shell_name="$1"
|
||
shift
|
||
|
||
local config_file
|
||
local candidate=""
|
||
local bun_install=""
|
||
|
||
for config_file in "$@"; do
|
||
[ -f "$config_file" ] || continue
|
||
|
||
if grep -Eq 'BUN_INSTALL|\.bun/bin|bun ' "$config_file" 2>/dev/null; then
|
||
bun_install="$(sed -nE 's/^[[:space:]]*(export[[:space:]]+)?BUN_INSTALL=["'\'']?([^"'\'']+)["'\'']?/\2/p' "$config_file" | tail -n 1)"
|
||
|
||
if [ -n "$bun_install" ]; then
|
||
bun_install="${bun_install/#\$HOME/$HOME}"
|
||
bun_install="${bun_install/#\~/$HOME}"
|
||
candidate="$bun_install/bin/bun"
|
||
if [ -x "$candidate" ]; then
|
||
FRONTEND_RUNTIME_BIN="$candidate"
|
||
FRONTEND_RUNTIME_SOURCE="${shell_name}-config"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
candidate="$HOME/.bun/bin/bun"
|
||
if [ -x "$candidate" ]; then
|
||
FRONTEND_RUNTIME_BIN="$candidate"
|
||
FRONTEND_RUNTIME_SOURCE="${shell_name}-config"
|
||
return 0
|
||
fi
|
||
fi
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
finish_wait_spinner() {
|
||
log_line "done" "$GREEN" "$1"
|
||
}
|
||
|
||
# Logging helpers
|
||
log_line() {
|
||
local label="$1"
|
||
local color="$2"
|
||
local message="$3"
|
||
clear_wait_spinner
|
||
printf "${DIM}·${NC} ${color}%-4s${NC} ${WHITE}%s${NC}\n" "$label" "$message"
|
||
resume_wait_session
|
||
}
|
||
|
||
log_step() {
|
||
log_line "run" "$BLUE" "$1"
|
||
}
|
||
|
||
log_warn() {
|
||
log_line "warn" "$YELLOW" "$1"
|
||
}
|
||
|
||
log_halt() {
|
||
log_line "stopped" "$RED" "$1"
|
||
}
|
||
|
||
log_error() {
|
||
log_line "fail" "$RED" "$1"
|
||
}
|
||
|
||
log_note() {
|
||
printf "${DIM} %s${NC}\n" "$1"
|
||
}
|
||
|
||
log_success() {
|
||
log_line "done" "$GREEN" "$1"
|
||
}
|
||
|
||
get_recommended_lan_ipv4() {
|
||
local candidate
|
||
|
||
while read -r candidate; do
|
||
[ -z "$candidate" ] && continue
|
||
case "$candidate" in
|
||
127.*|169.254.*|172.17.*|172.18.*|198.18.*|198.19.*|10.255.*)
|
||
continue
|
||
;;
|
||
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
|
||
printf "%s" "$candidate"
|
||
return 0
|
||
;;
|
||
esac
|
||
done <<EOF
|
||
$(hostname -I 2>/dev/null | tr ' ' '\n')
|
||
EOF
|
||
|
||
return 1
|
||
}
|
||
|
||
log_lan_access_notes() {
|
||
local frontend_port="$1"
|
||
local backend_port="$2"
|
||
local recommended_lan_ip=""
|
||
|
||
if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then
|
||
log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}"
|
||
log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health"
|
||
else
|
||
log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}"
|
||
log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health"
|
||
fi
|
||
}
|
||
|
||
log_motion_agent_access_notes() {
|
||
local motion_agent_port="$1"
|
||
local lan_enabled="${2:-0}"
|
||
local recommended_lan_ip=""
|
||
|
||
log_note "Motion Agent 本机: ws://127.0.0.1:${motion_agent_port}/ws/gestures"
|
||
if [ "$lan_enabled" -eq 1 ]; then
|
||
if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then
|
||
log_note "Motion Agent 局域网: ws://${recommended_lan_ip}:${motion_agent_port}/ws/gestures"
|
||
log_note "Earth 局域网页面可追加: ?motion=1&motionAgent=ws://${recommended_lan_ip}:${motion_agent_port}/ws/gestures"
|
||
else
|
||
log_note "Motion Agent 已对局域网开放,请使用本机局域网 IP 访问 :${motion_agent_port}/ws/gestures"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
print_splash() {
|
||
clear_wait_spinner
|
||
printf "%b" "$CYAN"
|
||
cat <<'EOF'
|
||
___ _ _ _ _ _
|
||
|_ _|_ __ | |_ ___| | (_) __ _ ___ _ __ | |_
|
||
| || '_ \| __/ _ \ | | |/ _` |/ _ \ '_ \| __|
|
||
| || | | | || __/ | | | (_| | __/ | | | |_
|
||
|___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__|
|
||
|___/
|
||
EOF
|
||
printf "%b" "$BLUE"
|
||
cat <<'EOF'
|
||
____ _ _
|
||
| _ \| | __ _ _ __ ___ | |_
|
||
| |_) | |/ _` | '_ \ / _ \| __|
|
||
| __/| | (_| | | | | __/| |_
|
||
|_| |_|\__,_|_| |_|\___| \__|
|
||
EOF
|
||
printf "%b" "$WHITE"
|
||
cat <<'EOF'
|
||
____ _
|
||
| _ \| | __ _ _ __
|
||
| |_) | |/ _` | '_ \
|
||
| __/| | (_| | | | |
|
||
|_| |_|\__,_|_| |_|
|
||
EOF
|
||
printf "%b\n\n" "$NC"
|
||
}
|
||
|
||
# Retry / dependency helpers
|
||
run_with_retry() {
|
||
local max_retries="$1"
|
||
local interval="$2"
|
||
local failure_message="$3"
|
||
local retry_label="$4"
|
||
shift 4
|
||
|
||
local attempt=1
|
||
|
||
while [ "$attempt" -le "$max_retries" ]; do
|
||
if run_command_with_spinner "$retry_label" "$@"; then
|
||
WAIT_SPINNER_DETAIL=""
|
||
return 0
|
||
fi
|
||
|
||
if [ "$attempt" -eq "$max_retries" ]; then
|
||
log_error "${failure_message}"
|
||
return 1
|
||
fi
|
||
|
||
animate_wait_spinner "${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试" "$interval"
|
||
attempt=$((attempt + 1))
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
docker_daemon_available() {
|
||
docker info >/dev/null 2>&1
|
||
}
|
||
|
||
log_docker_daemon_diagnostics() {
|
||
local docker_service_status=""
|
||
local docker_socket_status=""
|
||
local service_hint="sudo systemctl enable --now docker.socket && sudo systemctl restart docker"
|
||
|
||
docker_service_status="$(systemctl is-active docker 2>/dev/null || true)"
|
||
docker_socket_status="$(systemctl is-active docker.socket 2>/dev/null || true)"
|
||
|
||
if [ -S /var/run/docker.sock ] && [ ! -r /var/run/docker.sock ]; then
|
||
log_note "检测到 /var/run/docker.sock 存在,但当前用户无法访问。"
|
||
log_note "请确认当前用户已加入 docker 组,并重新登录会话。"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$docker_service_status" = "active" ]; then
|
||
log_note "Docker CLI 可执行,但当前会话仍无法连接 daemon。"
|
||
log_note "请先执行: docker info"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$docker_service_status" = "failed" ] || [ "$docker_socket_status" = "failed" ]; then
|
||
log_note "检测到 Docker daemon 当前未正常运行。"
|
||
log_note "可先执行: ${service_hint}"
|
||
log_note "如仍失败,继续查看: journalctl -u docker.service -n 50 --no-pager"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$docker_socket_status" != "active" ]; then
|
||
log_note "检测到 Docker socket 未激活,daemon 可能无法通过 systemd socket activation 启动。"
|
||
log_note "可先执行: ${service_hint}"
|
||
return 0
|
||
fi
|
||
|
||
log_note "当前无法连接 Docker daemon,请先执行: docker info"
|
||
log_note "若 daemon 未启动,可尝试: ${service_hint}"
|
||
}
|
||
|
||
print_database_failure_diagnostics() {
|
||
if ! docker_daemon_available; then
|
||
log_note "数据库容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。"
|
||
log_docker_daemon_diagnostics
|
||
return 0
|
||
fi
|
||
|
||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||
docker logs --tail 20 planet_redis 2>/dev/null || true
|
||
}
|
||
|
||
compute_ai_provider_build_fingerprint() {
|
||
(
|
||
cd "$SCRIPT_DIR" || exit 1
|
||
{
|
||
find aiprovider \
|
||
-type f \
|
||
! -path '*/__pycache__/*' \
|
||
! -name '.env' \
|
||
! -name '.env.*' \
|
||
! -name '*.pyc' \
|
||
! -name '*.pyo' \
|
||
| LC_ALL=C sort \
|
||
| xargs -r stat --format="%Y %s %n" 2>/dev/null
|
||
sha256sum docker-compose.yml docker-compose.simple.yml 2>/dev/null
|
||
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null
|
||
}
|
||
) | sha256sum | awk '{print $1}'
|
||
}
|
||
|
||
read_ai_provider_build_stamp() {
|
||
[ -f "$AI_PROVIDER_BUILD_STAMP_FILE" ] || return 1
|
||
tr -d '[:space:]' < "$AI_PROVIDER_BUILD_STAMP_FILE"
|
||
}
|
||
|
||
write_ai_provider_build_stamp() {
|
||
local fingerprint="$1"
|
||
mkdir -p "$(dirname "$AI_PROVIDER_BUILD_STAMP_FILE")"
|
||
printf "%s\n" "$fingerprint" > "$AI_PROVIDER_BUILD_STAMP_FILE"
|
||
}
|
||
|
||
ai_provider_image_exists() {
|
||
docker image inspect "$AI_PROVIDER_IMAGE_NAME" >/dev/null 2>&1
|
||
}
|
||
|
||
list_ai_provider_containers() {
|
||
docker ps -aq --filter "name=planet_aiprovider" 2>/dev/null || true
|
||
}
|
||
|
||
remove_ai_provider_containers() {
|
||
local container_ids
|
||
container_ids="$(list_ai_provider_containers)"
|
||
[ -n "$container_ids" ] || return 0
|
||
echo "$container_ids" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||
}
|
||
|
||
run_ai_provider_container_manually() {
|
||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
local env_file_args=()
|
||
|
||
if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then
|
||
env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env")
|
||
fi
|
||
if [ -s "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" ]; then
|
||
env_file_args+=(--env-file "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE")
|
||
fi
|
||
|
||
docker run -d \
|
||
--name "$AI_PROVIDER_CONTAINER_NAME" \
|
||
"${env_file_args[@]}" \
|
||
-p "${ai_provider_port}:8010" \
|
||
"$AI_PROVIDER_IMAGE_NAME" >/dev/null
|
||
}
|
||
|
||
write_ai_provider_runtime_env_file() {
|
||
local env_name env_value
|
||
|
||
: > "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE"
|
||
chmod 600 "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" 2>/dev/null || true
|
||
|
||
for env_name in "${AI_PROVIDER_RUNTIME_ENV_NAMES[@]}"; do
|
||
if [ -n "${(P)env_name+x}" ]; then
|
||
env_value="${(P)env_name}"
|
||
printf "%s=%s\n" "$env_name" "$env_value" >> "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE"
|
||
fi
|
||
done
|
||
|
||
export PLANET_AI_PROVIDER_RUNTIME_ENV_FILE
|
||
}
|
||
|
||
recreate_ai_provider_container() {
|
||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
|
||
remove_ai_provider_containers
|
||
|
||
if compose_up up -d aiprovider >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
|
||
run_ai_provider_container_manually "$ai_provider_port"
|
||
}
|
||
|
||
ensure_ai_provider_image_current() {
|
||
local current_fingerprint=""
|
||
local previous_fingerprint=""
|
||
|
||
current_fingerprint="$(compute_ai_provider_build_fingerprint)"
|
||
previous_fingerprint="$(read_ai_provider_build_stamp || true)"
|
||
|
||
if ai_provider_image_exists && [ -n "$previous_fingerprint" ] && [ "$current_fingerprint" = "$previous_fingerprint" ]; then
|
||
AI_PROVIDER_RECREATE_REQUIRED=0
|
||
return 0
|
||
fi
|
||
|
||
set_wait_detail "检测到 AI Provider 代码或配置变化,重建镜像"
|
||
|
||
if ! compose_supports_build; then
|
||
log_error "当前 docker compose 不支持 build,请检查 Docker / Compose 环境"
|
||
exit 1
|
||
fi
|
||
|
||
: > "$AI_PROVIDER_BUILD_LOG_FILE"
|
||
|
||
if ! build_ai_provider_image_with_fallback; then
|
||
clear_wait_spinner
|
||
log_error "AI Provider 镜像构建失败"
|
||
tail -20 "$AI_PROVIDER_BUILD_LOG_FILE" 2>/dev/null || true
|
||
log_buildx_diagnostics_if_needed
|
||
exit 1
|
||
fi
|
||
log_success "AI Provider 镜像已构建"
|
||
|
||
write_ai_provider_build_stamp "$current_fingerprint"
|
||
AI_PROVIDER_RECREATE_REQUIRED=1
|
||
}
|
||
|
||
build_ai_provider_image_with_fallback() {
|
||
if compose_available; then
|
||
set_wait_detail "使用 docker compose 构建 AI Provider 镜像"
|
||
if run_ai_provider_build_command "docker compose"; then
|
||
return 0
|
||
fi
|
||
if compose_v1_available; then
|
||
log_warn "docker compose 构建失败,回退到 docker-compose v1"
|
||
else
|
||
log_warn "docker compose 构建失败,当前环境未安装 docker-compose v1,无法继续回退"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
if compose_v1_available; then
|
||
set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像"
|
||
if run_ai_provider_build_command "docker-compose"; then
|
||
return 0
|
||
fi
|
||
return 1
|
||
fi
|
||
|
||
report_missing_compose
|
||
}
|
||
|
||
run_ai_provider_build_command() {
|
||
local compose_command="$1"
|
||
|
||
if [ "$VERBOSE" -eq 1 ]; then
|
||
run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider 2>&1 | tee \"$AI_PROVIDER_BUILD_LOG_FILE\""
|
||
return $?
|
||
fi
|
||
|
||
run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"
|
||
}
|
||
|
||
install_uv_if_needed() {
|
||
if command -v uv >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
|
||
set_wait_detail "未找到 uv,准备自动安装"
|
||
mkdir -p "$HOME/.local/bin"
|
||
|
||
if ! run_with_retry \
|
||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||
"uv 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \
|
||
"安装 uv" \
|
||
sh -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'; then
|
||
exit 1
|
||
fi
|
||
|
||
ensure_local_runtime_bins_on_path
|
||
|
||
if ! command -v uv >/dev/null 2>&1; then
|
||
log_error "uv 安装完成后仍未找到命令,请检查 ~/.local/bin"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
install_bun_if_needed() {
|
||
if command -v bun >/dev/null 2>&1 || [ -x "$HOME/.bun/bin/bun" ]; then
|
||
ensure_local_runtime_bins_on_path
|
||
return 0
|
||
fi
|
||
|
||
set_wait_detail "未找到 bun,准备自动安装"
|
||
mkdir -p "$HOME/.bun"
|
||
|
||
if ! run_with_retry \
|
||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||
"bun 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \
|
||
"安装 bun" \
|
||
sh -c 'curl -fsSL https://bun.sh/install | bash'; then
|
||
exit 1
|
||
fi
|
||
|
||
ensure_local_runtime_bins_on_path
|
||
|
||
if [ ! -x "$HOME/.bun/bin/bun" ] && ! command -v bun >/dev/null 2>&1; then
|
||
log_error "bun 安装完成后仍未找到命令,请检查 ~/.bun/bin"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
ensure_uv_backend_deps() {
|
||
set_wait_detail "检查后端 uv 环境"
|
||
|
||
if ! command -v uv >/dev/null 2>&1; then
|
||
install_uv_if_needed
|
||
fi
|
||
|
||
cd "$SCRIPT_DIR"
|
||
|
||
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||
log_warn "未检测到 .venv,正在执行 uv sync"
|
||
if ! run_with_retry \
|
||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||
"uv 环境初始化失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \
|
||
"uv sync" \
|
||
uv sync --group dev; then
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||
log_error "uv 环境初始化失败,未找到 .venv/bin/python"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
motion_agent_live_deps_ready() {
|
||
"$SCRIPT_DIR/.venv/bin/python" - <<'PY' >/dev/null 2>&1
|
||
import cv2 # noqa: F401
|
||
import mediapipe # noqa: F401
|
||
PY
|
||
}
|
||
|
||
detect_motion_agent_camera_indexes() {
|
||
local devices=""
|
||
local device=""
|
||
local index=""
|
||
|
||
if command -v v4l2-ctl >/dev/null 2>&1; then
|
||
devices="$(v4l2-ctl --list-devices 2>/dev/null | sed -nE 's/^[[:space:]]*\\/dev\\/video([0-9]+).*/\\1/p' || true)"
|
||
fi
|
||
|
||
if [ -z "$devices" ]; then
|
||
for device in /dev/video*(N); do
|
||
[ -e "$device" ] || continue
|
||
index="${device#/dev/video}"
|
||
[[ "$index" =~ ^[0-9]+$ ]] || continue
|
||
printf "%s\n" "$index"
|
||
done | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, -
|
||
return 0
|
||
fi
|
||
|
||
printf "%s\n" "$devices" | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, -
|
||
}
|
||
|
||
is_wsl_environment() {
|
||
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
|
||
}
|
||
|
||
ensure_motion_agent_live_deps() {
|
||
local auto_install="${PLANET_MOTION_AGENT_AUTO_INSTALL:-1}"
|
||
|
||
motion_agent_live_deps_ready && return 0
|
||
|
||
case "$auto_install" in
|
||
0|false|no|off)
|
||
log_error "Motion Agent live 模式缺少 mediapipe/opencv-python,且 PLANET_MOTION_AGENT_AUTO_INSTALL=0"
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
set_wait_detail "Motion Agent 缺少 CV 依赖,准备用 uv 自动安装"
|
||
if ! run_with_retry \
|
||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||
"Motion Agent CV 依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \
|
||
"uv add mediapipe opencv-python" \
|
||
uv add mediapipe opencv-python; then
|
||
exit 1
|
||
fi
|
||
|
||
if ! motion_agent_live_deps_ready; then
|
||
log_error "Motion Agent CV 依赖安装后仍无法导入 mediapipe/opencv-python"
|
||
log_note "可先用 --motion-agent-dry-run 验证 Web 端连接;真实摄像头识别需要当前 Python 版本可用的 mediapipe/opencv-python。"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# Frontend runtime resolution
|
||
resolve_frontend_runtime() {
|
||
local shell_bin
|
||
local candidate
|
||
|
||
if [ -n "$FRONTEND_RUNTIME_BIN" ] && [ -x "$FRONTEND_RUNTIME_BIN" ]; then
|
||
return 0
|
||
fi
|
||
|
||
ensure_local_runtime_bins_on_path
|
||
|
||
if [ -x "$HOME/.bun/bin/bun" ]; then
|
||
FRONTEND_RUNTIME_BIN="$HOME/.bun/bin/bun"
|
||
FRONTEND_RUNTIME_SOURCE="local-install"
|
||
WAIT_SPINNER_DETAIL="已找到 ~/.bun/bin/bun"
|
||
return 0
|
||
fi
|
||
|
||
set_wait_detail "读取 zsh 配置中的 bun"
|
||
if resolve_bun_from_shell_configs "zsh" "$HOME/.zshrc" "$HOME/.zprofile"; then
|
||
WAIT_SPINNER_DETAIL="已找到 zsh 配置中的 bun"
|
||
return 0
|
||
fi
|
||
|
||
if command -v zsh >/dev/null 2>&1; then
|
||
set_wait_detail "检查 zsh 中的 bun"
|
||
candidate="$(zsh -ic 'command -v bun 2>/dev/null || true' 2>/dev/null | tail -n 1)"
|
||
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
|
||
FRONTEND_RUNTIME_BIN="$candidate"
|
||
FRONTEND_RUNTIME_SOURCE="zsh"
|
||
WAIT_SPINNER_DETAIL="已找到 zsh 中的 bun"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
for shell_bin in pwsh powershell; do
|
||
if command -v "$shell_bin" >/dev/null 2>&1; then
|
||
set_wait_detail "检查 powershell 中的 bun"
|
||
candidate="$("$shell_bin" -Command "(Get-Command bun -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)" 2>/dev/null | tail -n 1 | tr -d '\r')"
|
||
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
|
||
FRONTEND_RUNTIME_BIN="$candidate"
|
||
FRONTEND_RUNTIME_SOURCE="powershell"
|
||
WAIT_SPINNER_DETAIL="已找到 powershell 中的 bun"
|
||
return 0
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if command -v bun >/dev/null 2>&1; then
|
||
set_wait_detail "检查当前环境中的 bun"
|
||
candidate="$(command -v bun)"
|
||
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
|
||
FRONTEND_RUNTIME_BIN="$candidate"
|
||
FRONTEND_RUNTIME_SOURCE="current-shell"
|
||
WAIT_SPINNER_DETAIL="已找到当前环境中的 bun"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
ensure_frontend_deps() {
|
||
local owns_wait_session=0
|
||
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then
|
||
start_wait_session "检查前端依赖"
|
||
owns_wait_session=1
|
||
else
|
||
set_wait_detail "检查前端依赖"
|
||
fi
|
||
|
||
set_wait_detail "解析前端运行时"
|
||
if ! resolve_frontend_runtime; then
|
||
install_bun_if_needed
|
||
if ! resolve_frontend_runtime; then
|
||
close_wait_session_context "$owns_wait_session"
|
||
log_error "未找到 bun,自动安装后仍无法解析运行时"
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
cd "$SCRIPT_DIR/frontend"
|
||
|
||
set_wait_detail "检查 Vite Bun 入口是否已安装"
|
||
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
|
||
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
|
||
if ! run_with_retry \
|
||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||
"前端依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \
|
||
"bun install" \
|
||
"$FRONTEND_RUNTIME_BIN" install; then
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||
close_wait_session_context "$owns_wait_session"
|
||
log_error "前端依赖安装失败,未找到 Vite Bun 入口"
|
||
exit 1
|
||
fi
|
||
|
||
close_wait_session_context "$owns_wait_session"
|
||
if [ "$owns_wait_session" -eq 0 ]; then
|
||
resume_wait_session
|
||
fi
|
||
}
|
||
|
||
# Health / readiness helpers
|
||
wait_for_http() {
|
||
local url="$1"
|
||
local attempts="$2"
|
||
local interval="$3"
|
||
local service_name="$4"
|
||
local attempt=1
|
||
local wait_message="检查${service_name}健康检查"
|
||
local wait_detail_prefix="请求"
|
||
|
||
set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})"
|
||
|
||
while [ "$attempt" -le "$attempts" ]; do
|
||
if curl -s --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1; then
|
||
finish_wait_spinner "${service_name}已就绪"
|
||
return 0
|
||
fi
|
||
|
||
set_wait_detail "${wait_detail_prefix} ${url} (${attempt}/${attempts})"
|
||
animate_wait_spinner "${wait_message}" "$interval"
|
||
attempt=$((attempt + 1))
|
||
done
|
||
|
||
clear_wait_spinner
|
||
return 1
|
||
}
|
||
|
||
get_log_tail_line() {
|
||
local log_file="$1"
|
||
local fallback="$2"
|
||
local last_line=""
|
||
local filtered_line=""
|
||
|
||
if [ -f "$log_file" ]; then
|
||
while IFS= read -r last_line; do
|
||
filtered_line="$(sanitize_wait_detail "$last_line")"
|
||
if [ -n "$filtered_line" ] &&
|
||
[[ ! "$filtered_line" =~ ^\$[[:space:]] ]] &&
|
||
[[ "$filtered_line" != "nohup: ignoring input" ]] &&
|
||
[[ "$filtered_line" != The\ CJS\ build\ of\ Vite\'s\ Node\ API\ is\ deprecated.* ]]; then
|
||
printf "%s" "$filtered_line"
|
||
return 0
|
||
fi
|
||
done < <(tail -n 20 "$log_file" 2>/dev/null | tac)
|
||
fi
|
||
|
||
if [ -z "$last_line" ]; then
|
||
printf "%s" "$fallback"
|
||
return 0
|
||
fi
|
||
|
||
sanitize_wait_detail "$last_line"
|
||
}
|
||
|
||
wait_for_frontend_ready() {
|
||
local frontend_port="$1"
|
||
local frontend_pid="$2"
|
||
local log_file="$3"
|
||
local attempts="$4"
|
||
local interval="$5"
|
||
local owns_wait_session=0
|
||
local tick=0
|
||
local max_ticks=$(( attempts * interval * WAIT_SPINNER_TICKS_PER_SECOND ))
|
||
local probe_every_ticks=$(( WAIT_SPINNER_TICKS_PER_SECOND / 2 ))
|
||
local detail_every_ticks=2
|
||
local detail=""
|
||
|
||
if [ "$max_ticks" -lt 1 ]; then
|
||
max_ticks=1
|
||
fi
|
||
if [ "$probe_every_ticks" -lt 1 ]; then
|
||
probe_every_ticks=1
|
||
fi
|
||
|
||
if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then
|
||
start_wait_session "启动前端服务"
|
||
owns_wait_session=1
|
||
else
|
||
set_wait_detail "启动 Vite 开发服务器"
|
||
fi
|
||
if [ "$VERBOSE" -eq 1 ]; then
|
||
WAIT_VERBOSE_LOG_FILE="$log_file"
|
||
fi
|
||
|
||
while [ "$tick" -lt "$max_ticks" ]; do
|
||
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
|
||
if ! kill -0 "$frontend_pid" 2>/dev/null; then
|
||
detail="$(get_log_tail_line "$log_file" "前端进程已退出")"
|
||
WAIT_SPINNER_DETAIL="$detail"
|
||
clear_wait_spinner
|
||
if [ -n "$detail" ]; then
|
||
log_note "$detail"
|
||
fi
|
||
close_wait_session_context "$owns_wait_session"
|
||
return 1
|
||
fi
|
||
|
||
if [ $(( tick % detail_every_ticks )) -eq 0 ]; then
|
||
detail="$(get_log_tail_line "$log_file" "等待 Vite 输出启动日志")"
|
||
WAIT_SPINNER_DETAIL="$detail"
|
||
render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP"
|
||
fi
|
||
|
||
if [ $(( tick % probe_every_ticks )) -eq 0 ] &&
|
||
curl -s --max-time "$HTTP_CHECK_MAX_TIME" "http://localhost:${frontend_port}" > /dev/null 2>&1; then
|
||
if [ "$owns_wait_session" -eq 1 ]; then
|
||
stop_wait_session
|
||
finish_wait_spinner "前端已就绪"
|
||
else
|
||
reset_wait_spinner_state
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
sleep 0.125
|
||
tick=$((tick + 1))
|
||
done
|
||
|
||
WAIT_SPINNER_DETAIL="$(get_log_tail_line "$log_file" "等待前端就绪超时")"
|
||
clear_wait_spinner
|
||
if [ -n "$WAIT_SPINNER_DETAIL" ]; then
|
||
log_note "$WAIT_SPINNER_DETAIL"
|
||
fi
|
||
close_wait_session_context "$owns_wait_session"
|
||
return 1
|
||
}
|
||
|
||
wait_for_container_health() {
|
||
local container_name="$1"
|
||
local attempts="$2"
|
||
local interval="$3"
|
||
local service_name="$4"
|
||
local attempt=1
|
||
local health_status
|
||
local wait_message="检查${service_name}容器健康检查"
|
||
local wait_detail_prefix="读取"
|
||
|
||
set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (1/${attempts})"
|
||
|
||
while [ "$attempt" -le "$attempts" ]; do
|
||
health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)"
|
||
|
||
if [ "$health_status" = "healthy" ] || [ "$health_status" = "running" ]; then
|
||
finish_wait_spinner "${service_name}容器健康检查已通过"
|
||
return 0
|
||
fi
|
||
|
||
set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (${attempt}/${attempts})"
|
||
animate_wait_spinner "${wait_message}" "$interval"
|
||
attempt=$((attempt + 1))
|
||
done
|
||
|
||
clear_wait_spinner
|
||
return 1
|
||
}
|
||
|
||
fail_unreleased_port() {
|
||
local port="$1"
|
||
|
||
if [ -z "$(collect_port_pids "$port" || true)" ]; then
|
||
log_error "端口 ${port} 当前环境内未发现占用进程,但端口仍不可用,请检查宿主机或外部环境占用"
|
||
print_port_listener_details "$port"
|
||
log_note "如果显示 Windows listener(如 svchost.exe / iphlpsvc),请优先用管理员 PowerShell 检查 portproxy:netsh interface portproxy show all;删除冲突转发或改用其他端口。"
|
||
else
|
||
log_error "端口 ${port} 清理失败,请检查占用进程"
|
||
if command -v lsof >/dev/null 2>&1; then
|
||
lsof -iTCP:"${port}" -sTCP:LISTEN -n -P 2>/dev/null || true
|
||
elif command -v ss >/dev/null 2>&1; then
|
||
ss -ltnp "( sport = :${port} )" 2>/dev/null || true
|
||
fi
|
||
print_windows_port_listener_details "$port" || true
|
||
log_note "请手动停止占用进程,或改用其他端口。"
|
||
fi
|
||
|
||
exit 1
|
||
}
|
||
|
||
retry_wait_for_port_release() {
|
||
local port="$1"
|
||
local service_name="$2"
|
||
local attempt=1
|
||
local pids=""
|
||
|
||
while [ "$attempt" -le "$PORT_PRESTART_RETRIES" ]; do
|
||
if wait_for_port_release "$port"; then
|
||
return 0
|
||
fi
|
||
|
||
pids="$(collect_port_pids "$port" || true)"
|
||
if [ -n "$pids" ]; then
|
||
log_warn "${service_name}端口 ${port} 仍有监听进程,继续清理 (${attempt}/${PORT_PRESTART_RETRIES})"
|
||
for pid in $pids; do
|
||
terminate_process_tree KILL "$pid"
|
||
done
|
||
else
|
||
log_warn "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,等待释放 (${attempt}/${PORT_PRESTART_RETRIES})"
|
||
print_port_listener_details "$port"
|
||
fi
|
||
|
||
sleep "$PORT_PRESTART_RETRY_INTERVAL"
|
||
attempt=$((attempt + 1))
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
force_cleanup_external_port_listener() {
|
||
local port="$1"
|
||
local service_name="$2"
|
||
local output=""
|
||
local saw_windows_listener=0
|
||
local saw_no_windows_listener=0
|
||
local saw_cleanup_failure=0
|
||
|
||
is_wsl_environment || return 1
|
||
command -v powershell.exe >/dev/null 2>&1 || return 1
|
||
|
||
log_warn "检测到 WSL 环境,正在检查 ${service_name} 端口 ${port} 的 Windows 侧监听"
|
||
output="$(
|
||
powershell.exe -NoProfile -Command "
|
||
\$ErrorActionPreference = 'Continue'
|
||
\$port = [int]${port}
|
||
\$connections = @(Get-NetTCPConnection -LocalPort \$port -State Listen -ErrorAction SilentlyContinue)
|
||
if (-not \$connections -or \$connections.Count -eq 0) {
|
||
'no-windows-listener'
|
||
exit 0
|
||
}
|
||
\$seen = @{}
|
||
foreach (\$connection in \$connections) {
|
||
\$pidValue = [int]\$connection.OwningProcess
|
||
if (\$seen.ContainsKey(\$pidValue)) { continue }
|
||
\$seen[\$pidValue] = \$true
|
||
\$process = Get-CimInstance Win32_Process -Filter \"ProcessId=\$pidValue\" -ErrorAction SilentlyContinue
|
||
\$processName = if (\$process.Name) { \$process.Name } else { 'unknown' }
|
||
\$services = @(Get-CimInstance Win32_Service -ErrorAction SilentlyContinue | Where-Object { \$_.ProcessId -eq \$pidValue })
|
||
\$serviceNames = @(\$services | Select-Object -ExpandProperty Name)
|
||
'attempt-windows-cleanup pid={0} process={1} services={2}' -f \$pidValue, \$processName, (\$serviceNames -join ',')
|
||
|
||
foreach (\$service in \$services) {
|
||
try {
|
||
Stop-Service -Name \$service.Name -Force -ErrorAction Stop
|
||
'stopped-service {0}' -f \$service.Name
|
||
} catch {
|
||
'failed-stop-service {0}: {1}' -f \$service.Name, \$_.Exception.Message
|
||
}
|
||
}
|
||
|
||
try {
|
||
Stop-Process -Id \$pidValue -Force -ErrorAction Stop
|
||
'stopped-process {0}' -f \$pidValue
|
||
} catch {
|
||
'failed-stop-process {0}: {1}' -f \$pidValue, \$_.Exception.Message
|
||
}
|
||
}
|
||
" 2>&1 | tr -d '\r'
|
||
)"
|
||
|
||
while IFS= read -r line; do
|
||
[ -n "$line" ] || continue
|
||
case "$line" in
|
||
no-windows-listener)
|
||
saw_no_windows_listener=1
|
||
log_note "Windows 侧未发现端口 ${port} 监听,继续启动"
|
||
;;
|
||
stopped-service*|stopped-process*)
|
||
saw_windows_listener=1
|
||
printf "${GREEN} %s${NC}\n" "$line"
|
||
;;
|
||
failed-*)
|
||
saw_windows_listener=1
|
||
saw_cleanup_failure=1
|
||
printf "${DIM} %s${NC}\n" "$line"
|
||
;;
|
||
attempt-windows-cleanup*)
|
||
saw_windows_listener=1
|
||
printf "${DIM} %s${NC}\n" "$line"
|
||
;;
|
||
*)
|
||
printf "${DIM} %s${NC}\n" "$line"
|
||
;;
|
||
esac
|
||
done <<EOF
|
||
$output
|
||
EOF
|
||
|
||
if wait_for_port_release "$port" 10 0.3; then
|
||
return 0
|
||
fi
|
||
|
||
if [ "$saw_no_windows_listener" -eq 1 ] && [ "$saw_windows_listener" -eq 0 ]; then
|
||
return 0
|
||
fi
|
||
|
||
if [ "$saw_windows_listener" -eq 0 ]; then
|
||
log_warn "Windows 侧未发现端口 ${port} 监听,但端口仍不可绑定"
|
||
elif [ "$saw_cleanup_failure" -eq 1 ]; then
|
||
log_warn "Windows 侧监听清理失败,端口 ${port} 仍不可绑定"
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# Service startup helpers
|
||
wait_for_database_health() {
|
||
wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" &&
|
||
wait_for_container_health "planet_redis" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "Redis"
|
||
}
|
||
|
||
wait_for_postgres_health() {
|
||
wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL"
|
||
}
|
||
|
||
start_database_services() {
|
||
docker start planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||
}
|
||
|
||
restart_database_services() {
|
||
docker restart planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||
}
|
||
|
||
start_postgres_service() {
|
||
docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1
|
||
}
|
||
|
||
# Backend lifecycle helpers
|
||
cleanup_backend_processes() {
|
||
local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
|
||
terminate_backend_processes TERM "$backend_port"
|
||
|
||
if ! wait_for_port_release "$backend_port" 15 0.2; then
|
||
terminate_backend_processes KILL "$backend_port"
|
||
if ! wait_for_port_release "$backend_port" 15 0.2; then
|
||
if force_cleanup_external_port_listener "$backend_port" "后端"; then
|
||
return 0
|
||
fi
|
||
wait_for_port_release "$backend_port" 15 0.2 || return 1
|
||
fi
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
start_backend_with_retry() {
|
||
local backend_port="$1"
|
||
local retry=1
|
||
|
||
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
|
||
if ! cleanup_backend_processes "$backend_port"; then
|
||
fail_unreleased_port "$backend_port"
|
||
fi
|
||
cd "$SCRIPT_DIR/backend"
|
||
: > /tmp/planet_backend.log
|
||
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
|
||
BACKEND_PID=$!
|
||
if [ "$VERBOSE" -eq 1 ]; then
|
||
WAIT_VERBOSE_LOG_FILE="/tmp/planet_backend.log"
|
||
fi
|
||
|
||
if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then
|
||
return 0
|
||
fi
|
||
|
||
kill "$BACKEND_PID" 2>/dev/null || true
|
||
if backend_log_indicates_port_conflict "/tmp/planet_backend.log"; then
|
||
cleanup_backend_processes "$backend_port" || fail_unreleased_port "$backend_port"
|
||
if wait_for_port_release "$backend_port" 5 0.2; then
|
||
log_warn "后端端口冲突已清理,立即重试启动"
|
||
retry=$((retry + 1))
|
||
continue
|
||
fi
|
||
report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log"
|
||
return 1
|
||
fi
|
||
animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
|
||
clear_wait_spinner
|
||
report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log" || true
|
||
return 1
|
||
}
|
||
|
||
start_ai_provider_service() {
|
||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
local retry=1
|
||
set_wait_detail "启动 AI Provider"
|
||
|
||
write_ai_provider_runtime_env_file
|
||
ensure_ai_provider_image_current
|
||
|
||
while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do
|
||
if [ "$AI_PROVIDER_RECREATE_REQUIRED" -eq 1 ]; then
|
||
if recreate_ai_provider_container "$ai_provider_port"; then
|
||
AI_PROVIDER_RECREATE_REQUIRED=0
|
||
if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
|
||
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||
return 0
|
||
fi
|
||
fi
|
||
elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then
|
||
if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
|
||
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then
|
||
log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次"
|
||
docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true
|
||
exit 1
|
||
fi
|
||
|
||
remove_ai_provider_containers
|
||
AI_PROVIDER_RECREATE_REQUIRED=1
|
||
animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
|
||
log_error "AI Provider 启动失败"
|
||
docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true
|
||
exit 1
|
||
}
|
||
|
||
ai_provider_service_healthy() {
|
||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
|
||
docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1
|
||
curl -s --max-time "$HTTP_CHECK_MAX_TIME" \
|
||
"http://localhost:${ai_provider_port}/health" >/dev/null 2>&1
|
||
}
|
||
|
||
ensure_database_services_healthy() {
|
||
local retry=1
|
||
|
||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||
if start_database_services && wait_for_database_health; then
|
||
return 0
|
||
fi
|
||
|
||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||
log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||
print_database_failure_diagnostics
|
||
exit 1
|
||
fi
|
||
|
||
docker restart planet_postgres planet_redis >/dev/null 2>&1 || true
|
||
animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
}
|
||
|
||
ensure_postgres_service_healthy() {
|
||
local retry=1
|
||
|
||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||
if start_postgres_service && wait_for_postgres_health; then
|
||
return 0
|
||
fi
|
||
|
||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||
log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||
if ! docker_daemon_available; then
|
||
log_note "PostgreSQL 容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。"
|
||
log_docker_daemon_diagnostics
|
||
else
|
||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||
fi
|
||
exit 1
|
||
fi
|
||
|
||
docker restart planet_postgres >/dev/null 2>&1 || true
|
||
animate_wait_spinner "PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
}
|
||
|
||
restart_database_service() {
|
||
local retry=1
|
||
set_wait_detail "重启数据库"
|
||
|
||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||
if restart_database_services && wait_for_database_health; then
|
||
return 0
|
||
fi
|
||
|
||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||
log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||
print_database_failure_diagnostics
|
||
exit 1
|
||
fi
|
||
|
||
animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试" "$DATABASE_RETRY_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
}
|
||
|
||
start_backend_service() {
|
||
local backend_port="$1"
|
||
local backend_port_requested="$2"
|
||
local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
|
||
|
||
set_wait_detail "启动数据库"
|
||
ensure_database_services_healthy
|
||
log_success "启动数据库已就绪"
|
||
|
||
# Backend depends on AI Provider reachability, but a backend-only restart
|
||
# should reuse the existing healthy provider instead of rebuilding or
|
||
# restarting it.
|
||
if ai_provider_service_healthy "$ai_provider_port"; then
|
||
log_note "AI Provider 已健康,复用现有服务,跳过启动/重建"
|
||
else
|
||
log_note "AI Provider 未就绪,正在启动本地代理服务"
|
||
start_ai_provider_service "$ai_provider_port"
|
||
fi
|
||
|
||
if [ "$backend_port_requested" -eq 1 ]; then
|
||
kill_port_if_requested "$backend_port" "后端"
|
||
fi
|
||
|
||
set_wait_detail "启动后端"
|
||
ensure_uv_backend_deps
|
||
if ! start_backend_with_retry "$backend_port"; then
|
||
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次"
|
||
tail -10 /tmp/planet_backend.log
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# Port / process helpers
|
||
validate_port() {
|
||
local port="$1"
|
||
|
||
if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
||
log_error "非法端口: ${port}"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
collect_port_pids() {
|
||
local port="$1"
|
||
local pids=""
|
||
local pid=""
|
||
|
||
if command -v lsof >/dev/null 2>&1; then
|
||
pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||
if [ -n "$pids" ]; then
|
||
printf "%s\n" "$pids" | awk '!seen[$0]++'
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
if command -v fuser >/dev/null 2>&1; then
|
||
pids="$(fuser "${port}/tcp" 2>/dev/null || true)"
|
||
if [ -n "$pids" ]; then
|
||
for pid in $pids; do
|
||
printf "%s\n" "$pid"
|
||
done | awk '!seen[$0]++'
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
if command -v ss >/dev/null 2>&1; then
|
||
pids="$(ss -ltnp "( sport = :${port} )" 2>/dev/null | sed -nE 's/.*pid=([0-9]+).*/\1/p' || true)"
|
||
if [ -n "$pids" ]; then
|
||
printf "%s\n" "$pids" | awk '!seen[$0]++'
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
terminate_process_group() {
|
||
local signal="$1"
|
||
local pid="$2"
|
||
local pgid=""
|
||
|
||
[ -n "$pid" ] || return 0
|
||
kill -0 "$pid" 2>/dev/null || return 0
|
||
|
||
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
|
||
[ -n "$pgid" ] || return 0
|
||
|
||
kill "-${signal}" -- "-${pgid}" 2>/dev/null || true
|
||
}
|
||
|
||
terminate_backend_processes() {
|
||
local signal="$1"
|
||
local backend_port="$2"
|
||
local pids=""
|
||
local pid=""
|
||
|
||
pids="$(pgrep -f "uvicorn" 2>/dev/null || true)"
|
||
for pid in $pids; do
|
||
terminate_process_group "$signal" "$pid"
|
||
terminate_process_tree "$signal" "$pid"
|
||
done
|
||
|
||
pids="$(collect_port_pids "$backend_port" || true)"
|
||
for pid in $pids; do
|
||
terminate_process_group "$signal" "$pid"
|
||
terminate_process_tree "$signal" "$pid"
|
||
done
|
||
}
|
||
|
||
terminate_process_tree() {
|
||
local signal="$1"
|
||
local pid="$2"
|
||
local child_pid
|
||
|
||
[ -n "$pid" ] || return 0
|
||
kill -0 "$pid" 2>/dev/null || return 0
|
||
|
||
if command -v pgrep >/dev/null 2>&1; then
|
||
while IFS= read -r child_pid; do
|
||
[ -n "$child_pid" ] || continue
|
||
terminate_process_tree "$signal" "$child_pid"
|
||
done < <(pgrep -P "$pid" 2>/dev/null || true)
|
||
fi
|
||
|
||
kill "-${signal}" "$pid" 2>/dev/null || true
|
||
}
|
||
|
||
can_bind_port() {
|
||
local port="$1"
|
||
|
||
if command -v python3 >/dev/null 2>&1; then
|
||
python3 - "$port" <<'PY' >/dev/null 2>&1
|
||
import socket
|
||
import sys
|
||
|
||
port = int(sys.argv[1])
|
||
sockets = []
|
||
try:
|
||
for family, host in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
|
||
try:
|
||
sock = socket.socket(family)
|
||
if family == socket.AF_INET6 and hasattr(socket, "IPV6_V6ONLY"):
|
||
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||
sock.bind((host, port))
|
||
sockets.append(sock)
|
||
except OSError as exc:
|
||
if getattr(exc, "errno", None) in (socket.EAFNOSUPPORT, getattr(socket, "EADDRNOTAVAIL", -1)):
|
||
continue
|
||
raise
|
||
finally:
|
||
for sock in sockets:
|
||
try:
|
||
sock.close()
|
||
except OSError:
|
||
pass
|
||
PY
|
||
return
|
||
fi
|
||
|
||
if command -v ss >/dev/null 2>&1; then
|
||
! ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE ":${port}$"
|
||
return
|
||
fi
|
||
if command -v lsof >/dev/null 2>&1; then
|
||
[ -z "$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null)" ]
|
||
return
|
||
fi
|
||
}
|
||
|
||
wait_for_port_release() {
|
||
local port="$1"
|
||
local max_attempts="${2:-$PORT_RELEASE_ATTEMPTS}"
|
||
local interval="${3:-$PORT_RELEASE_INTERVAL}"
|
||
local attempt=1
|
||
|
||
while [ "$attempt" -le "$max_attempts" ]; do
|
||
if can_bind_port "$port"; then
|
||
return 0
|
||
fi
|
||
sleep "$interval"
|
||
attempt=$((attempt + 1))
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
kill_port_if_requested() {
|
||
local port="$1"
|
||
local service_name="$2"
|
||
local pids=""
|
||
local pid=""
|
||
|
||
log_warn "检测 ${service_name} 端口 ${port} 占用"
|
||
|
||
pids="$(collect_port_pids "$port" || true)"
|
||
|
||
if [ -z "$pids" ] && can_bind_port "$port"; then
|
||
log_success "端口 ${port} 未被占用"
|
||
return 0
|
||
fi
|
||
|
||
if [ -z "$pids" ]; then
|
||
if force_cleanup_external_port_listener "$port" "$service_name"; then
|
||
log_success "端口 ${port} 已释放"
|
||
return 0
|
||
fi
|
||
fail_unreleased_port "$port"
|
||
fi
|
||
|
||
log_step "发现端口 ${port} 占用,正在终止"
|
||
for pid in $pids; do
|
||
terminate_process_tree TERM "$pid"
|
||
done
|
||
|
||
if retry_wait_for_port_release "$port" "$service_name"; then
|
||
log_success "端口 ${port} 已释放"
|
||
return 0
|
||
fi
|
||
|
||
if [ -z "$(collect_port_pids "$port" || true)" ]; then
|
||
if force_cleanup_external_port_listener "$port" "$service_name"; then
|
||
log_success "端口 ${port} 已释放"
|
||
return 0
|
||
fi
|
||
fail_unreleased_port "$port"
|
||
fi
|
||
|
||
log_warn "端口 ${port} 仍被占用,正在强制终止"
|
||
pids="$(collect_port_pids "$port" || true)"
|
||
for pid in $pids; do
|
||
terminate_process_tree KILL "$pid"
|
||
done
|
||
|
||
if retry_wait_for_port_release "$port" "$service_name"; then
|
||
log_success "端口 ${port} 已释放"
|
||
return 0
|
||
fi
|
||
|
||
if [ -z "$(collect_port_pids "$port" || true)" ]; then
|
||
if force_cleanup_external_port_listener "$port" "$service_name"; then
|
||
log_success "端口 ${port} 已释放"
|
||
return 0
|
||
fi
|
||
fail_unreleased_port "$port"
|
||
fi
|
||
|
||
fail_unreleased_port "$port"
|
||
}
|
||
|
||
frontend_log_indicates_port_conflict() {
|
||
local log_file="$1"
|
||
|
||
[ -f "$log_file" ] || return 1
|
||
grep -q "Port .* is already in use" "$log_file" 2>/dev/null
|
||
}
|
||
|
||
backend_log_indicates_port_conflict() {
|
||
local log_file="$1"
|
||
|
||
[ -f "$log_file" ] || return 1
|
||
grep -Eiq "Address already in use|Errno 98" "$log_file" 2>/dev/null
|
||
}
|
||
|
||
print_windows_port_listener_details() {
|
||
local port="$1"
|
||
local output=""
|
||
|
||
command -v powershell.exe >/dev/null 2>&1 || return 1
|
||
|
||
output="$(
|
||
powershell.exe -NoProfile -Command "
|
||
\$ErrorActionPreference = 'SilentlyContinue'
|
||
\$port = [int]${port}
|
||
\$connections = Get-NetTCPConnection -LocalPort \$port -State Listen
|
||
foreach (\$connection in \$connections) {
|
||
\$owningProcessId = \$connection.OwningProcess
|
||
\$process = Get-CimInstance Win32_Process -Filter \"ProcessId=\$owningProcessId\"
|
||
\$services = Get-CimInstance Win32_Service | Where-Object { \$_.ProcessId -eq \$owningProcessId } | Select-Object -ExpandProperty Name
|
||
\$processName = if (\$process.Name) { \$process.Name } else { 'unknown' }
|
||
\$serviceText = if (\$services) { ' services=' + (\$services -join ',') } else { '' }
|
||
'Windows listener: {0}:{1} pid={2} process={3}{4}' -f \$connection.LocalAddress, \$connection.LocalPort, \$owningProcessId, \$processName, \$serviceText
|
||
}
|
||
" 2>/dev/null | tr -d '\r'
|
||
)"
|
||
|
||
[ -n "$output" ] || return 1
|
||
while IFS= read -r line; do
|
||
[ -n "$line" ] || continue
|
||
printf "${DIM} %s${NC}\n" "$line"
|
||
done <<EOF
|
||
$output
|
||
EOF
|
||
return 0
|
||
}
|
||
|
||
print_port_listener_details() {
|
||
local port="$1"
|
||
local found=0
|
||
local pid=""
|
||
local command_line=""
|
||
local lsof_output=""
|
||
local ss_output=""
|
||
local line=""
|
||
|
||
if command -v lsof >/dev/null 2>&1; then
|
||
lsof_output="$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||
if [ -n "$lsof_output" ]; then
|
||
printf "%s\n" "$lsof_output" | awk '{print " " $0}'
|
||
found=1
|
||
fi
|
||
fi
|
||
|
||
if command -v ss >/dev/null 2>&1; then
|
||
ss_output="$(ss -ltnpH "( sport = :${port} )" 2>/dev/null || true)"
|
||
while IFS= read -r line; do
|
||
[ -n "$line" ] || continue
|
||
printf "${DIM} ss: %s${NC}\n" "$line"
|
||
found=1
|
||
done <<EOF
|
||
$ss_output
|
||
EOF
|
||
fi
|
||
|
||
for pid in $(collect_port_pids "$port" || true); do
|
||
command_line="$(ps -p "$pid" -o args= 2>/dev/null | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')"
|
||
[ -n "$command_line" ] || command_line="未知命令"
|
||
printf "${DIM} pid %s: %s${NC}\n" "$pid" "$command_line"
|
||
found=1
|
||
done
|
||
|
||
if print_windows_port_listener_details "$port"; then
|
||
found=1
|
||
fi
|
||
|
||
if [ "$found" -eq 0 ]; then
|
||
log_note "未能在当前环境内定位端口 ${port} 的监听进程,可能被宿主机或外部网络命名空间占用。"
|
||
fi
|
||
}
|
||
|
||
report_backend_port_conflict_if_needed() {
|
||
local backend_port="$1"
|
||
local log_file="$2"
|
||
|
||
if backend_log_indicates_port_conflict "$log_file" || ! can_bind_port "$backend_port"; then
|
||
log_error "后端地址已被占用: 0.0.0.0:${backend_port} / 127.0.0.1:${backend_port} / [::1]:${backend_port}"
|
||
print_port_listener_details "$backend_port"
|
||
return 0
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
# Frontend lifecycle helpers
|
||
cleanup_frontend_processes() {
|
||
local frontend_port="${1:-$DEFAULT_FRONTEND_PORT}"
|
||
local tracked_pid=""
|
||
|
||
if [ -f "$FRONTEND_PID_FILE" ]; then
|
||
tracked_pid="$(cat "$FRONTEND_PID_FILE" 2>/dev/null || true)"
|
||
if [ -n "$tracked_pid" ]; then
|
||
terminate_process_tree TERM "$tracked_pid"
|
||
sleep 1
|
||
terminate_process_tree KILL "$tracked_pid"
|
||
fi
|
||
rm -f "$FRONTEND_PID_FILE"
|
||
fi
|
||
|
||
pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
|
||
pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
|
||
pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
|
||
}
|
||
|
||
start_frontend_with_retry() {
|
||
local frontend_port="$1"
|
||
local frontend_port_requested="${2:-0}"
|
||
local frontend_lan_enabled="${3:-0}"
|
||
local retry=1
|
||
|
||
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
|
||
cleanup_frontend_processes "$frontend_port"
|
||
if [ -n "$(collect_port_pids "$frontend_port" || true)" ] &&
|
||
! retry_wait_for_port_release "$frontend_port" "前端"; then
|
||
log_warn "前端端口 ${frontend_port} 预清理后仍需由 Vite 启动流程确认"
|
||
fi
|
||
cd "$SCRIPT_DIR/frontend"
|
||
: > /tmp/planet_frontend.log
|
||
local -a frontend_args
|
||
frontend_args=("$FRONTEND_VITE_ENTRY")
|
||
if [ "$frontend_lan_enabled" -eq 1 ]; then
|
||
frontend_args+=(--host 0.0.0.0)
|
||
fi
|
||
frontend_args+=(--port "$frontend_port" --strictPort)
|
||
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 &
|
||
FRONTEND_PID=$!
|
||
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
|
||
|
||
if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "/tmp/planet_frontend.log" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then
|
||
return 0
|
||
fi
|
||
|
||
cleanup_frontend_processes "$frontend_port"
|
||
reset_wait_spinner_state
|
||
|
||
if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "/tmp/planet_frontend.log"; then
|
||
kill_port_if_requested "$frontend_port" "前端"
|
||
fi
|
||
|
||
animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL"
|
||
retry=$((retry + 1))
|
||
done
|
||
|
||
clear_wait_spinner
|
||
return 1
|
||
}
|
||
|
||
start_frontend_service() {
|
||
local frontend_port="$1"
|
||
local frontend_port_requested="$2"
|
||
local frontend_lan_enabled="${3:-0}"
|
||
|
||
if [ "$frontend_port_requested" -eq 1 ]; then
|
||
kill_port_if_requested "$frontend_port" "前端"
|
||
fi
|
||
|
||
start_wait_session "检查前端依赖"
|
||
ensure_frontend_deps
|
||
stop_wait_session
|
||
log_success "前端依赖已就绪"
|
||
|
||
start_wait_session "启动前端服务"
|
||
if [ "$frontend_lan_enabled" -eq 1 ]; then
|
||
set_wait_detail "启动 Vite 开发服务器(局域网开放)"
|
||
else
|
||
set_wait_detail "启动 Vite 开发服务器"
|
||
fi
|
||
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
|
||
stop_wait_session
|
||
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
||
tail -10 /tmp/planet_frontend.log
|
||
exit 1
|
||
fi
|
||
stop_wait_session
|
||
log_success "前端已就绪"
|
||
}
|
||
|
||
# Argument parsing / command helpers
|
||
parse_service_args() {
|
||
BACKEND_PORT="$DEFAULT_BACKEND_PORT"
|
||
FRONTEND_PORT="$DEFAULT_FRONTEND_PORT"
|
||
AI_PROVIDER_PORT="$DEFAULT_AI_PROVIDER_PORT"
|
||
MOTION_AGENT_PORT="$DEFAULT_MOTION_AGENT_PORT"
|
||
MOTION_AGENT_CAMERA_INDEXES="${MOTION_AGENT_CAMERA_INDEXES:-}"
|
||
MOTION_AGENT_CAMERA_URLS="${MOTION_AGENT_CAMERA_URLS:-}"
|
||
BACKEND_PORT_REQUESTED=0
|
||
FRONTEND_PORT_REQUESTED=0
|
||
AI_PROVIDER_REQUESTED=0
|
||
MOTION_AGENT_REQUESTED=0
|
||
MOTION_AGENT_DRY_RUN=0
|
||
DATABASE_REQUESTED=0
|
||
FRONTEND_LAN_ENABLED=0
|
||
|
||
case "${PLANET_START_MOTION_AGENT:-0}" in
|
||
1|true|yes|on)
|
||
MOTION_AGENT_REQUESTED=1
|
||
;;
|
||
esac
|
||
case "${MOTION_AGENT_DRY_RUN:-0}" in
|
||
1|true|yes|on)
|
||
MOTION_AGENT_DRY_RUN=1
|
||
;;
|
||
esac
|
||
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
-b|--backend-port)
|
||
BACKEND_PORT_REQUESTED=1
|
||
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
|
||
BACKEND_PORT="$2"
|
||
shift 2
|
||
else
|
||
shift 1
|
||
fi
|
||
;;
|
||
-f|--frontend-port)
|
||
FRONTEND_PORT_REQUESTED=1
|
||
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
|
||
FRONTEND_PORT="$2"
|
||
shift 2
|
||
else
|
||
shift 1
|
||
fi
|
||
;;
|
||
-a|--ai-provider-port)
|
||
AI_PROVIDER_REQUESTED=1
|
||
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
|
||
AI_PROVIDER_PORT="$2"
|
||
shift 2
|
||
else
|
||
shift 1
|
||
fi
|
||
;;
|
||
-m|--motion-agent)
|
||
MOTION_AGENT_REQUESTED=1
|
||
shift 1
|
||
;;
|
||
--motion-agent-port)
|
||
MOTION_AGENT_REQUESTED=1
|
||
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
|
||
MOTION_AGENT_PORT="$2"
|
||
shift 2
|
||
else
|
||
log_error "--motion-agent-port 需要端口号"
|
||
exit 1
|
||
fi
|
||
;;
|
||
--motion-agent-dry-run)
|
||
MOTION_AGENT_REQUESTED=1
|
||
MOTION_AGENT_DRY_RUN=1
|
||
shift 1
|
||
;;
|
||
--motion-agent-camera-indexes)
|
||
MOTION_AGENT_REQUESTED=1
|
||
if [ -n "$2" ]; then
|
||
MOTION_AGENT_CAMERA_INDEXES="$2"
|
||
shift 2
|
||
else
|
||
log_error "--motion-agent-camera-indexes 需要逗号分隔的摄像头 index,例如 0,1"
|
||
exit 1
|
||
fi
|
||
;;
|
||
--motion-agent-camera-urls)
|
||
MOTION_AGENT_REQUESTED=1
|
||
if [ -n "$2" ]; then
|
||
MOTION_AGENT_CAMERA_URLS="$2"
|
||
shift 2
|
||
else
|
||
log_error "--motion-agent-camera-urls 需要逗号分隔的 RTSP/HTTP 摄像头 URL"
|
||
exit 1
|
||
fi
|
||
;;
|
||
-d|--database)
|
||
DATABASE_REQUESTED=1
|
||
shift 1
|
||
;;
|
||
--allow-lan)
|
||
FRONTEND_LAN_ENABLED=1
|
||
shift 1
|
||
;;
|
||
-v|--verbose)
|
||
VERBOSE=1
|
||
shift 1
|
||
;;
|
||
*)
|
||
log_error "未知参数: $1"
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
validate_port "$BACKEND_PORT"
|
||
validate_port "$FRONTEND_PORT"
|
||
validate_port "$AI_PROVIDER_PORT"
|
||
validate_port "$MOTION_AGENT_PORT"
|
||
}
|
||
|
||
cleanup_exit_containers() {
|
||
local exit_containers
|
||
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
|
||
if [ -n "$exit_containers" ]; then
|
||
log_step "清理残留 Exit 容器"
|
||
echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||
log_success "残留容器已清理"
|
||
fi
|
||
}
|
||
|
||
stop_container_if_running() {
|
||
local container_name="$1"
|
||
local service_name="$2"
|
||
local container_status=""
|
||
|
||
container_status="$(docker inspect --format '{{.State.Status}}' "$container_name" 2>/dev/null || true)"
|
||
if [ "$container_status" = "running" ]; then
|
||
docker stop "$container_name" >/dev/null 2>&1 || true
|
||
log_halt "${service_name}已停止"
|
||
fi
|
||
}
|
||
|
||
stop_backend_service() {
|
||
if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
|
||
cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
|
||
log_halt "后端服务已停止"
|
||
fi
|
||
}
|
||
|
||
stop_ai_provider_service() {
|
||
stop_container_if_running "$AI_PROVIDER_CONTAINER_NAME" "AI Provider"
|
||
}
|
||
|
||
stop_frontend_service() {
|
||
if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
|
||
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
|
||
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
||
cleanup_frontend_processes "$FRONTEND_PORT"
|
||
fi
|
||
log_halt "前端服务已停止"
|
||
return 0
|
||
fi
|
||
|
||
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
|
||
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
||
cleanup_frontend_processes "$FRONTEND_PORT"
|
||
fi
|
||
}
|
||
|
||
motion_agent_pids() {
|
||
local pids=""
|
||
|
||
if [ -f "$MOTION_AGENT_PID_FILE" ]; then
|
||
pids="$(cat "$MOTION_AGENT_PID_FILE" 2>/dev/null || true)"
|
||
if [ -n "$pids" ] && kill -0 "$pids" 2>/dev/null; then
|
||
printf "%s\n" "$pids"
|
||
fi
|
||
fi
|
||
|
||
pgrep -f "python.*-m motion_agent" 2>/dev/null || true
|
||
}
|
||
|
||
cleanup_motion_agent_processes() {
|
||
local signal="${1:-TERM}"
|
||
local pid
|
||
|
||
while IFS= read -r pid; do
|
||
[ -n "$pid" ] || continue
|
||
terminate_process_group "$signal" "$pid"
|
||
terminate_process_tree "$signal" "$pid"
|
||
done < <(motion_agent_pids | awk '!seen[$0]++')
|
||
|
||
rm -f "$MOTION_AGENT_PID_FILE"
|
||
}
|
||
|
||
wait_for_motion_agent_ready() {
|
||
local port="$1"
|
||
local pid="$2"
|
||
local attempt=1
|
||
|
||
while [ "$attempt" -le 20 ]; do
|
||
if ! kill -0 "$pid" 2>/dev/null; then
|
||
return 1
|
||
fi
|
||
if ! can_bind_port "$port"; then
|
||
return 0
|
||
fi
|
||
sleep 0.2
|
||
attempt=$((attempt + 1))
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
start_motion_agent_service() {
|
||
local motion_agent_port="$1"
|
||
local dry_run="$2"
|
||
local lan_enabled="${3:-0}"
|
||
local bind_host="127.0.0.1"
|
||
local -a motion_args
|
||
|
||
ensure_uv_backend_deps
|
||
if [ "$dry_run" -eq 0 ] && [ -z "$MOTION_AGENT_CAMERA_URLS" ] && [ -z "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||
MOTION_AGENT_CAMERA_INDEXES="$(detect_motion_agent_camera_indexes)"
|
||
if [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||
log_note "Motion Agent 自动发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}"
|
||
else
|
||
if is_wsl_environment; then
|
||
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头"
|
||
log_note "当前看起来是 WSL;Windows 摄像头通常不会自动出现在 /dev/video*。"
|
||
log_note "真实识别请选择一种方式:"
|
||
log_note " 1. 用 --motion-agent-camera-urls 接手机/RTSP/HTTP 摄像头流"
|
||
log_note " 2. 用 usbipd-win 把 USB 摄像头透传到 WSL,再重试"
|
||
log_note " 3. 仅验证 WebSocket/调试面板时,显式加 --motion-agent-dry-run"
|
||
case "${PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK:-0}" in
|
||
1|true|yes|on)
|
||
log_warn "已设置 PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1,降级为 dry-run"
|
||
dry_run=1
|
||
;;
|
||
*)
|
||
MOTION_AGENT_CAMERA_MISSING=1
|
||
;;
|
||
esac
|
||
else
|
||
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头,默认尝试 index 0"
|
||
fi
|
||
fi
|
||
fi
|
||
if [ "${MOTION_AGENT_CAMERA_MISSING:-0}" -eq 1 ]; then
|
||
log_error "Motion Agent live 模式缺少可用摄像头"
|
||
log_note "WSL 示例:./planet.sh restart -m --motion-agent-camera-urls http://<手机IP>:8080/video"
|
||
log_note "或仅调试协议:./planet.sh restart -m --motion-agent-dry-run"
|
||
exit 1
|
||
fi
|
||
if [ "$dry_run" -eq 0 ]; then
|
||
ensure_motion_agent_live_deps
|
||
fi
|
||
cleanup_motion_agent_processes TERM
|
||
wait_for_port_release "$motion_agent_port" 10 0.2 || true
|
||
|
||
if ! can_bind_port "$motion_agent_port"; then
|
||
log_error "Motion Agent 端口已被占用: ${motion_agent_port}"
|
||
print_port_listener_details "$motion_agent_port"
|
||
exit 1
|
||
fi
|
||
|
||
: > "$MOTION_AGENT_LOG_FILE"
|
||
if [ "$lan_enabled" -eq 1 ]; then
|
||
bind_host="0.0.0.0"
|
||
fi
|
||
|
||
motion_args=(-m motion_agent --host "$bind_host" --port "$motion_agent_port")
|
||
if [ -n "$MOTION_AGENT_CAMERA_URLS" ]; then
|
||
motion_args+=(--camera-urls "$MOTION_AGENT_CAMERA_URLS")
|
||
elif [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
|
||
motion_args+=(--camera-indexes "$MOTION_AGENT_CAMERA_INDEXES")
|
||
fi
|
||
if [ "$dry_run" -eq 1 ]; then
|
||
motion_args+=(--dry-run)
|
||
fi
|
||
|
||
cd "$SCRIPT_DIR"
|
||
nohup "$SCRIPT_DIR/.venv/bin/python" "${motion_args[@]}" > "$MOTION_AGENT_LOG_FILE" 2>&1 &
|
||
MOTION_AGENT_PID=$!
|
||
printf "%s" "$MOTION_AGENT_PID" > "$MOTION_AGENT_PID_FILE"
|
||
|
||
if wait_for_motion_agent_ready "$motion_agent_port" "$MOTION_AGENT_PID"; then
|
||
log_success "Motion Agent 已就绪"
|
||
log_motion_agent_access_notes "$motion_agent_port" "$lan_enabled"
|
||
return 0
|
||
fi
|
||
|
||
log_error "Motion Agent 启动失败"
|
||
tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || true
|
||
log_note "如只需验证 Web 端连接,可加 --motion-agent-dry-run。"
|
||
log_note "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。"
|
||
log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。"
|
||
log_note "依赖缺失时脚本会自动执行 uv add mediapipe opencv-python。"
|
||
cleanup_motion_agent_processes TERM
|
||
exit 1
|
||
}
|
||
|
||
stop_motion_agent_service() {
|
||
if [ -f "$MOTION_AGENT_PID_FILE" ] || pgrep -f "python.*-m motion_agent" >/dev/null 2>&1; then
|
||
cleanup_motion_agent_processes TERM
|
||
log_halt "Motion Agent 已停止"
|
||
fi
|
||
}
|
||
|
||
create_user() {
|
||
local username
|
||
local password
|
||
local password_confirm
|
||
local email
|
||
local generated_email
|
||
local role="viewer"
|
||
local is_admin
|
||
|
||
ensure_uv_backend_deps
|
||
|
||
log_step "启动数据库"
|
||
ensure_postgres_service_healthy
|
||
sleep 2
|
||
|
||
log_step "创建用户"
|
||
read -r -p "用户名: " username
|
||
|
||
if [ -z "$username" ]; then
|
||
log_error "用户名不能为空"
|
||
exit 1
|
||
fi
|
||
|
||
generated_email="${username}@planet.local"
|
||
read -r -p "邮箱(留空自动使用 ${generated_email}): " email
|
||
if [ -z "$email" ]; then
|
||
email="$generated_email"
|
||
fi
|
||
|
||
read -r -s -p "密码: " password
|
||
echo ""
|
||
read -r -s -p "确认密码: " password_confirm
|
||
echo ""
|
||
|
||
if [ -z "$password" ]; then
|
||
log_error "密码不能为空"
|
||
exit 1
|
||
fi
|
||
|
||
if [ "${#password}" -lt 8 ]; then
|
||
log_error "密码长度不能少于 8 位"
|
||
exit 1
|
||
fi
|
||
|
||
if [ "$password" != "$password_confirm" ]; then
|
||
log_error "两次输入的密码不一致"
|
||
exit 1
|
||
fi
|
||
|
||
read -r -p "是否为管理员? (Y/N): " is_admin
|
||
if [[ "$is_admin" =~ ^[Yy]$ ]]; then
|
||
role="super_admin"
|
||
fi
|
||
|
||
cd "$SCRIPT_DIR/backend"
|
||
PLANET_CREATEUSER_USERNAME="$username" \
|
||
PLANET_CREATEUSER_PASSWORD="$password" \
|
||
PLANET_CREATEUSER_EMAIL="$email" \
|
||
PLANET_CREATEUSER_ROLE="$role" \
|
||
PYTHONPATH="$SCRIPT_DIR/backend" \
|
||
"$SCRIPT_DIR/.venv/bin/python" - <<'PY'
|
||
import asyncio
|
||
import os
|
||
|
||
from sqlalchemy import text
|
||
|
||
from app.core.security import get_password_hash
|
||
from app.db.session import async_session_factory
|
||
from app.models.user import User
|
||
|
||
|
||
async def main():
|
||
username = os.environ["PLANET_CREATEUSER_USERNAME"].strip()
|
||
password = os.environ["PLANET_CREATEUSER_PASSWORD"]
|
||
email = os.environ["PLANET_CREATEUSER_EMAIL"].strip()
|
||
role = os.environ["PLANET_CREATEUSER_ROLE"].strip()
|
||
|
||
async with async_session_factory() as session:
|
||
result = await session.execute(
|
||
text("SELECT id FROM users WHERE username = :username OR email = :email"),
|
||
{"username": username, "email": email},
|
||
)
|
||
if result.fetchone():
|
||
raise SystemExit("用户名已存在")
|
||
|
||
user = User(
|
||
username=username,
|
||
email=email,
|
||
password_hash=get_password_hash(password),
|
||
role=role,
|
||
is_active=True,
|
||
)
|
||
session.add(user)
|
||
await session.commit()
|
||
|
||
|
||
asyncio.run(main())
|
||
PY
|
||
|
||
log_success "用户创建成功"
|
||
log_note "用户名: ${username}"
|
||
log_note "角色: ${role}"
|
||
log_note "邮箱: ${email}"
|
||
}
|
||
|
||
# Top-level commands
|
||
start() {
|
||
parse_service_args "$@"
|
||
cleanup_exit_containers
|
||
|
||
print_splash
|
||
|
||
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
|
||
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
|
||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
|
||
fi
|
||
|
||
log_success "启动完成"
|
||
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
|
||
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
|
||
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
|
||
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
|
||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||
log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED"
|
||
fi
|
||
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
|
||
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
|
||
fi
|
||
}
|
||
|
||
stop() {
|
||
log_warn "停止服务"
|
||
stop_backend_service
|
||
stop_ai_provider_service
|
||
stop_frontend_service
|
||
stop_motion_agent_service
|
||
stop_container_if_running "planet_postgres" "PostgreSQL"
|
||
stop_container_if_running "planet_redis" "Redis"
|
||
log_success "已停止"
|
||
}
|
||
|
||
restart() {
|
||
parse_service_args "$@"
|
||
cleanup_exit_containers
|
||
|
||
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
|
||
stop
|
||
sleep 1
|
||
start "$@"
|
||
return 0
|
||
fi
|
||
|
||
log_warn "按需重启服务"
|
||
|
||
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||
restart_database_service
|
||
fi
|
||
|
||
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
|
||
stop_ai_provider_service
|
||
sleep 1
|
||
start_ai_provider_service "$AI_PROVIDER_PORT"
|
||
fi
|
||
|
||
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
||
stop_backend_service
|
||
sleep 1
|
||
start_backend_service "$BACKEND_PORT" 1 "$AI_PROVIDER_PORT"
|
||
fi
|
||
|
||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||
stop_frontend_service
|
||
sleep 1
|
||
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
|
||
fi
|
||
|
||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||
stop_motion_agent_service
|
||
sleep 1
|
||
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
|
||
fi
|
||
|
||
echo ""
|
||
log_success "重启完成"
|
||
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||
log_note "数据库: planet_postgres, planet_redis"
|
||
fi
|
||
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
|
||
log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}"
|
||
fi
|
||
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
||
log_note "后端: http://localhost:${BACKEND_PORT}"
|
||
fi
|
||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
||
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
|
||
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
|
||
fi
|
||
fi
|
||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||
log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED"
|
||
fi
|
||
}
|
||
|
||
health() {
|
||
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}"
|
||
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||
|
||
echo ""
|
||
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}"
|
||
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
||
echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}"
|
||
else
|
||
echo -e "${DIM} 后端:${NC} ${RED}offline${NC}"
|
||
fi
|
||
|
||
if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then
|
||
echo -e "${DIM} AI Provider:${NC} ${GREEN}online${NC}"
|
||
else
|
||
echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}"
|
||
fi
|
||
|
||
if curl -s http://localhost:3000 > /dev/null 2>&1; then
|
||
echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}"
|
||
else
|
||
echo -e "${DIM} 前端:${NC} ${RED}offline${NC}"
|
||
fi
|
||
|
||
if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1 || [ -f "$MOTION_AGENT_PID_FILE" ]; then
|
||
echo -e "${DIM} Motion Agent:${NC} ${GREEN}online${NC}"
|
||
else
|
||
echo -e "${DIM} Motion Agent:${NC} ${RED}offline${NC}"
|
||
fi
|
||
}
|
||
|
||
log() {
|
||
case "$1" in
|
||
-f|--frontend)
|
||
log_step "查看前端日志"
|
||
log_note "按 Ctrl+C 退出"
|
||
tail -f /tmp/planet_frontend.log
|
||
;;
|
||
-b|--backend)
|
||
log_step "查看后端日志"
|
||
log_note "按 Ctrl+C 退出"
|
||
tail -f /tmp/planet_backend.log
|
||
;;
|
||
-a|--ai-provider)
|
||
log_step "查看 AI Provider 日志"
|
||
log_note "按 Ctrl+C 退出"
|
||
docker logs -f planet_aiprovider
|
||
;;
|
||
-m|--motion-agent)
|
||
log_step "查看 Motion Agent 日志"
|
||
log_note "按 Ctrl+C 退出"
|
||
tail -f "$MOTION_AGENT_LOG_FILE"
|
||
;;
|
||
*)
|
||
log_step "查看最近日志"
|
||
log_note "后端"
|
||
tail -20 /tmp/planet_backend.log 2>/dev/null || log_note "无日志"
|
||
log_note "AI Provider"
|
||
docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志"
|
||
log_note "前端"
|
||
tail -20 /tmp/planet_frontend.log 2>/dev/null || log_note "无日志"
|
||
log_note "Motion Agent"
|
||
tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || log_note "无日志"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
parse_global_args() {
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
-v|--verbose)
|
||
VERBOSE=1
|
||
shift 1
|
||
;;
|
||
*)
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
|
||
GLOBAL_ARG_REMAINDER=("$@")
|
||
}
|
||
|
||
parse_global_args "$@"
|
||
set -- "${GLOBAL_ARG_REMAINDER[@]}"
|
||
|
||
case "$1" in
|
||
start)
|
||
shift
|
||
start "$@"
|
||
;;
|
||
stop)
|
||
stop
|
||
;;
|
||
restart)
|
||
shift
|
||
restart "$@"
|
||
;;
|
||
createuser)
|
||
create_user
|
||
;;
|
||
health)
|
||
health
|
||
;;
|
||
log)
|
||
log "$2"
|
||
;;
|
||
*)
|
||
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
|
||
log_note "全局参数: -v, --verbose 在当前执行行下方滚动显示最多 5 行命令输出"
|
||
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> -m/--motion-agent --motion-agent-port <端口> --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-dry-run --allow-lan --verbose"
|
||
log_note "stop 停止服务"
|
||
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose"
|
||
log_note "createuser 交互创建用户"
|
||
log_note "health 检查健康状态"
|
||
log_note "log 查看日志"
|
||
log_note "log -f 查看前端日志"
|
||
log_note "log -b 查看后端日志"
|
||
log_note "log -a 查看 AI Provider 日志"
|
||
log_note "log -m 查看 Motion Agent 日志"
|
||
;;
|
||
esac
|