Files
PROJECT-CONTORL/projectctl.sh
beabigegg a7c452ffd8 fix: resolve duplicate header and improve Redis management
- Dashboard: Remove redundant header (Layout already provides it)
- projectctl.sh: Add start_redis/stop_redis functions for automatic
  Redis lifecycle management on project start/stop
- rate_limiter.py: Add fallback to memory storage when Redis unavailable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:53:16 +08:00

552 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$ROOT_DIR/backend"
FRONTEND_DIR="$ROOT_DIR/frontend"
LOG_ROOT="$ROOT_DIR/logs"
RUN_ID="$(date +"%Y%m%d-%H%M%S")"
RUN_DIR="$LOG_ROOT/run-$RUN_ID"
LATEST_LINK="$LOG_ROOT/latest"
BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}"
BACKEND_PORT="${BACKEND_PORT:-8000}"
FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}"
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
BACKEND_RELOAD="${BACKEND_RELOAD:-1}"
AUTO_INSTALL="${AUTO_INSTALL:-0}"
FOLLOW_LOGS="${FOLLOW_LOGS:-0}"
CONDA_ENV="${CONDA_ENV:-pjctrl}"
# Python path detection (prefer conda env)
PYTHON_BIN=""
detect_python() {
# Check for conda environment
local conda_python
if command -v conda >/dev/null 2>&1; then
conda_python="$(conda run -n "$CONDA_ENV" which python 2>/dev/null || true)"
if [[ -n "$conda_python" && -x "$conda_python" ]]; then
PYTHON_BIN="$conda_python"
return
fi
fi
# Fallback to system python3
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="python3"
return
fi
log "ERROR: No Python interpreter found"
exit 1
}
ENV_LOG=""
RUN_LOG=""
HEALTH_LOG=""
usage() {
cat <<'EOF'
Usage: ./projectctl.sh [start|stop|status|health]
Commands:
start Check environment, start backend/frontend, run health checks
stop Stop backend/frontend based on latest pid files
status Show current process status from latest pid files
health Run health checks against running services
Environment variables:
BACKEND_HOST (default: 127.0.0.1)
BACKEND_PORT (default: 8000)
FRONTEND_HOST (default: 127.0.0.1)
FRONTEND_PORT (default: 5173)
BACKEND_RELOAD (default: 1) use 0 to disable --reload
AUTO_INSTALL (default: 0) set 1 to auto npm install if missing
FOLLOW_LOGS (default: 0) set 1 to tail logs after start
CONDA_ENV (default: pjctrl) conda environment name for backend
EOF
}
log() {
local msg="$*"
local ts
ts="$(date +"%H:%M:%S")"
echo "[$ts] $msg" | tee -a "$RUN_LOG"
}
env_log() {
echo "$*" >> "$ENV_LOG"
}
health_log() {
echo "$*" | tee -a "$HEALTH_LOG"
}
ensure_dirs() {
mkdir -p "$RUN_DIR"
mkdir -p "$LOG_ROOT"
ln -sfn "$RUN_DIR" "$LATEST_LINK"
ENV_LOG="$RUN_DIR/env.log"
RUN_LOG="$RUN_DIR/run.log"
HEALTH_LOG="$RUN_DIR/health.log"
}
require_cmds() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
log "ERROR: Missing required commands: ${missing[*]}"
exit 1
fi
}
get_env_value() {
local key="$1"
local file="$2"
local line
line="$(grep -E "^${key}=" "$file" | tail -n1 || true)"
if [[ -z "$line" ]]; then
echo ""
return
fi
local val="${line#*=}"
val="$(printf '%s' "$val" | sed -e 's/#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
echo "$val"
}
check_env_file() {
local env_file="$BACKEND_DIR/.env"
if [[ ! -f "$env_file" ]]; then
log "WARN: backend/.env not found. Copy backend/.env.example and set values."
return 1
fi
local required_keys=(
MYSQL_HOST
MYSQL_PORT
MYSQL_USER
MYSQL_PASSWORD
MYSQL_DATABASE
REDIS_HOST
REDIS_PORT
JWT_SECRET_KEY
AUTH_API_URL
SYSTEM_ADMIN_EMAIL
)
local optional_keys=(
ENCRYPTION_MASTER_KEY
)
env_log "Env file: $env_file"
for key in "${required_keys[@]}"; do
local val
val="$(get_env_value "$key" "$env_file")"
if [[ -z "$val" ]]; then
log "ERROR: Missing or empty env key: $key"
env_log "MISSING: $key"
return 1
fi
env_log "OK: $key"
done
for key in "${optional_keys[@]}"; do
local val
val="$(get_env_value "$key" "$env_file")"
if [[ -z "$val" ]]; then
log "WARN: Optional env key not set: $key"
env_log "WARN: $key not set"
else
env_log "OK: $key"
fi
done
return 0
}
check_python_deps() {
"$PYTHON_BIN" - <<'PY'
import importlib
import sys
required = ["fastapi", "uvicorn", "sqlalchemy", "redis", "pydantic", "alembic"]
missing = []
for mod in required:
try:
importlib.import_module(mod)
except Exception:
missing.append(mod)
if missing:
sys.stderr.write("Missing python packages: " + ", ".join(missing) + "\n")
sys.exit(1)
PY
}
check_redis() {
local env_file="$BACKEND_DIR/.env"
local redis_host redis_port
redis_host="$(get_env_value "REDIS_HOST" "$env_file")"
redis_port="$(get_env_value "REDIS_PORT" "$env_file")"
redis_host="${redis_host:-localhost}"
redis_port="${redis_port:-6379}"
if command -v redis-cli >/dev/null 2>&1; then
if ! redis-cli -h "$redis_host" -p "$redis_port" ping >/dev/null 2>&1; then
log "WARN: Redis not reachable at $redis_host:$redis_port"
return 1
fi
env_log "Redis: OK ($redis_host:$redis_port)"
else
# Try nc as fallback
if command -v nc >/dev/null 2>&1; then
if ! nc -z "$redis_host" "$redis_port" >/dev/null 2>&1; then
log "WARN: Redis port not open at $redis_host:$redis_port"
return 1
fi
env_log "Redis: Port reachable ($redis_host:$redis_port)"
else
log "WARN: Cannot verify Redis (no redis-cli or nc)"
fi
fi
return 0
}
start_redis() {
local env_file="$BACKEND_DIR/.env"
local redis_host redis_port
redis_host="$(get_env_value "REDIS_HOST" "$env_file")"
redis_port="$(get_env_value "REDIS_PORT" "$env_file")"
redis_host="${redis_host:-localhost}"
redis_port="${redis_port:-6379}"
# Only manage local Redis
if [[ "$redis_host" != "localhost" && "$redis_host" != "127.0.0.1" ]]; then
log "Redis host is remote ($redis_host), skipping local Redis management"
return 0
fi
# Check if Redis is already running
if command -v redis-cli >/dev/null 2>&1; then
if redis-cli -h "$redis_host" -p "$redis_port" ping >/dev/null 2>&1; then
log "Redis already running at $redis_host:$redis_port"
return 0
fi
fi
# Try to start Redis using brew services (macOS)
if command -v brew >/dev/null 2>&1; then
if brew services list 2>/dev/null | grep -q "redis"; then
log "Starting Redis via brew services..."
brew services start redis >/dev/null 2>&1 || true
sleep 2
if redis-cli -h "$redis_host" -p "$redis_port" ping >/dev/null 2>&1; then
log "Redis started successfully"
env_log "Redis: Started via brew services"
return 0
fi
fi
fi
# Try to start redis-server directly
if command -v redis-server >/dev/null 2>&1; then
log "Starting Redis server directly..."
redis-server --daemonize yes --port "$redis_port" >/dev/null 2>&1 || true
sleep 1
if redis-cli -h "$redis_host" -p "$redis_port" ping >/dev/null 2>&1; then
log "Redis started successfully"
env_log "Redis: Started directly"
return 0
fi
fi
log "WARN: Could not start Redis automatically"
return 1
}
stop_redis() {
local env_file="$BACKEND_DIR/.env"
local redis_host redis_port
redis_host="$(get_env_value "REDIS_HOST" "$env_file")"
redis_port="$(get_env_value "REDIS_PORT" "$env_file")"
redis_host="${redis_host:-localhost}"
redis_port="${redis_port:-6379}"
# Only manage local Redis
if [[ "$redis_host" != "localhost" && "$redis_host" != "127.0.0.1" ]]; then
echo "Redis host is remote ($redis_host), skipping local Redis management"
return 0
fi
# Try to stop Redis using brew services (macOS)
if command -v brew >/dev/null 2>&1; then
if brew services list 2>/dev/null | grep -q "redis.*started"; then
echo "Stopping Redis via brew services..."
brew services stop redis >/dev/null 2>&1 || true
return 0
fi
fi
# Try to stop redis-server via redis-cli
if command -v redis-cli >/dev/null 2>&1; then
if redis-cli -h "$redis_host" -p "$redis_port" ping >/dev/null 2>&1; then
echo "Stopping Redis via redis-cli shutdown..."
redis-cli -h "$redis_host" -p "$redis_port" shutdown nosave >/dev/null 2>&1 || true
return 0
fi
fi
echo "Redis: not running or not managed locally"
return 0
}
check_mysql() {
local env_file="$BACKEND_DIR/.env"
local mysql_host mysql_port
mysql_host="$(get_env_value "MYSQL_HOST" "$env_file")"
mysql_port="$(get_env_value "MYSQL_PORT" "$env_file")"
mysql_host="${mysql_host:-localhost}"
mysql_port="${mysql_port:-3306}"
# Just check if port is reachable (don't require mysql client)
if command -v nc >/dev/null 2>&1; then
if ! nc -z -w 3 "$mysql_host" "$mysql_port" >/dev/null 2>&1; then
log "WARN: MySQL port not reachable at $mysql_host:$mysql_port"
return 1
fi
env_log "MySQL: Port reachable ($mysql_host:$mysql_port)"
else
log "WARN: Cannot verify MySQL connectivity (no nc command)"
fi
return 0
}
check_node_deps() {
if [[ ! -d "$FRONTEND_DIR/node_modules" ]]; then
if [[ "$AUTO_INSTALL" == "1" ]]; then
log "node_modules missing; running npm install..."
(cd "$FRONTEND_DIR" && npm install)
else
log "ERROR: frontend/node_modules missing. Run npm install or set AUTO_INSTALL=1."
exit 1
fi
fi
}
port_in_use() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
lsof -iTCP:"$port" -sTCP:LISTEN -n -P >/dev/null 2>&1
return $?
fi
if command -v nc >/dev/null 2>&1; then
nc -z 127.0.0.1 "$port" >/dev/null 2>&1
return $?
fi
return 1
}
check_ports() {
if port_in_use "$BACKEND_PORT"; then
log "ERROR: Port $BACKEND_PORT is already in use."
exit 1
fi
if port_in_use "$FRONTEND_PORT"; then
log "ERROR: Port $FRONTEND_PORT is already in use."
exit 1
fi
}
start_backend() {
local reload_args=()
if [[ "$BACKEND_RELOAD" == "1" ]]; then
reload_args=("--reload")
fi
log "Starting backend on $BACKEND_HOST:$BACKEND_PORT (python: $PYTHON_BIN)..."
(
cd "$BACKEND_DIR"
nohup env PYTHONPATH="$BACKEND_DIR" \
"$PYTHON_BIN" -m uvicorn app.main:app \
--host "$BACKEND_HOST" \
--port "$BACKEND_PORT" \
"${reload_args[@]+"${reload_args[@]}"}" \
> "$RUN_DIR/backend.log" 2>&1 &
echo $! > "$RUN_DIR/backend.pid"
)
}
start_frontend() {
log "Starting frontend on $FRONTEND_HOST:$FRONTEND_PORT..."
(
cd "$FRONTEND_DIR"
nohup npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" \
> "$RUN_DIR/frontend.log" 2>&1 &
echo $! > "$RUN_DIR/frontend.pid"
)
}
wait_for_url() {
local url="$1"
local name="$2"
local retries="${3:-30}"
local delay="${4:-1}"
local i=0
while (( i < retries )); do
if curl -fsS "$url" >/dev/null 2>&1; then
health_log "$(date +"%Y-%m-%d %H:%M:%S") OK $name $url"
return 0
fi
sleep "$delay"
i=$((i + 1))
done
health_log "$(date +"%Y-%m-%d %H:%M:%S") FAIL $name $url"
return 1
}
run_health_checks() {
log "Running health checks..."
local backend_url="http://$BACKEND_HOST:$BACKEND_PORT/health"
local frontend_url="http://$FRONTEND_HOST:$FRONTEND_PORT/"
wait_for_url "$backend_url" "backend" 40 1 || log "WARN: Backend health check failed."
wait_for_url "$frontend_url" "frontend" 40 1 || log "WARN: Frontend health check failed."
}
latest_run_dir() {
if [[ -L "$LATEST_LINK" ]]; then
readlink "$LATEST_LINK"
return
fi
ls -dt "$LOG_ROOT"/run-* 2>/dev/null | head -n1 || true
}
stop_service() {
local name="$1"
local pidfile="$2"
if [[ ! -f "$pidfile" ]]; then
echo "$name: pid file not found."
return
fi
local pid
pid="$(cat "$pidfile")"
if kill -0 "$pid" >/dev/null 2>&1; then
echo "Stopping $name (pid $pid)..."
# Kill child processes first (for uvicorn workers, vite children, etc.)
if command -v pgrep >/dev/null 2>&1; then
local children
children="$(pgrep -P "$pid" 2>/dev/null || true)"
if [[ -n "$children" ]]; then
echo "$children" | xargs kill 2>/dev/null || true
fi
fi
kill -TERM "$pid" 2>/dev/null || true
# Wait briefly for graceful shutdown
sleep 1
# Force kill if still running
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
else
echo "$name: process not running."
fi
rm -f "$pidfile"
}
status_service() {
local name="$1"
local pidfile="$2"
if [[ ! -f "$pidfile" ]]; then
echo "$name: pid file not found."
return
fi
local pid
pid="$(cat "$pidfile")"
if kill -0 "$pid" >/dev/null 2>&1; then
echo "$name: running (pid $pid)"
else
echo "$name: not running"
fi
}
start() {
ensure_dirs
log "Run directory: $RUN_DIR"
require_cmds node npm curl
detect_python
env_log "Timestamp: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
env_log "Python: $("$PYTHON_BIN" --version 2>&1)"
env_log "Python Path: $PYTHON_BIN"
env_log "Node: $(node --version 2>&1)"
env_log "NPM: $(npm --version 2>&1)"
check_env_file
check_python_deps
check_node_deps
start_redis || true # Start Redis if not running (warn but don't fail)
check_redis || true # Verify Redis is available
check_mysql || true # Warn but don't fail
check_ports
start_backend
start_frontend
run_health_checks
log "Logs: $RUN_DIR"
log "Backend log: $RUN_DIR/backend.log"
log "Frontend log: $RUN_DIR/frontend.log"
log "Health log: $RUN_DIR/health.log"
if [[ "$FOLLOW_LOGS" == "1" ]]; then
log "Tailing logs. Press Ctrl+C to stop tailing."
tail -n 200 -f "$RUN_DIR/backend.log" "$RUN_DIR/frontend.log"
fi
}
stop() {
local dir
dir="$(latest_run_dir)"
if [[ -z "$dir" ]]; then
echo "No run directory found."
return
fi
stop_service "backend" "$dir/backend.pid"
stop_service "frontend" "$dir/frontend.pid"
stop_redis
}
status() {
local dir
dir="$(latest_run_dir)"
if [[ -z "$dir" ]]; then
echo "No run directory found."
return
fi
status_service "backend" "$dir/backend.pid"
status_service "frontend" "$dir/frontend.pid"
}
health() {
local dir
dir="$(latest_run_dir)"
if [[ -z "$dir" ]]; then
echo "No run directory found."
return
fi
RUN_DIR="$dir"
RUN_LOG="$dir/run.log"
HEALTH_LOG="$dir/health.log"
run_health_checks
}
case "${1:-start}" in
start) start ;;
stop) stop ;;
status) status ;;
health) health ;;
-h|--help) usage ;;
*) usage; exit 1 ;;
esac