Backend: - LOW-002: Add Query validation with max page size limits (100) - LOW-003: Replace magic strings with TaskStatus.is_done flag - LOW-004: Add 'creation' trigger type validation - Add action_executor.py with UpdateFieldAction and AutoAssignAction Frontend: - LOW-005: Replace TypeScript 'any' with 'unknown' + type guards - LOW-006: Add ConfirmModal component with A11Y support - LOW-007: Add ToastContext for user feedback notifications - LOW-009: Add Skeleton components (17 loading states replaced) - LOW-010: Setup Vitest with 21 tests for ConfirmModal and Skeleton Components updated: - App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx - ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx - Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx - NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
462 lines
11 KiB
Bash
Executable File
462 lines
11 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
|
|
}
|
|
|
|
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
|
|
check_redis || true # Warn but don't fail
|
|
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"
|
|
}
|
|
|
|
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
|