mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-17 07:36:58 +00:00
372 lines
9.5 KiB
Bash
Executable File
372 lines
9.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
HERMES_HOME="${HERMES_HOME:-${HOME}/.hermes}"
|
|
PID_FILE="${HERMES_WEBUI_PID_FILE:-${HERMES_HOME}/webui.pid}"
|
|
LOG_FILE="${HERMES_WEBUI_LOG_FILE:-${HERMES_HOME}/webui.log}"
|
|
STATE_FILE="${HERMES_WEBUI_CTL_STATE_FILE:-${HERMES_HOME}/webui.ctl.env}"
|
|
DEFAULT_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${HERMES_HOME}/webui}"
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: ./ctl.sh <command> [args]
|
|
|
|
Commands:
|
|
start [bootstrap args...] Start Hermes WebUI as a background daemon
|
|
stop Stop the daemon started by ctl.sh
|
|
restart [bootstrap args...] Stop, then start again
|
|
status Show daemon, host/port, log, and health status
|
|
logs [--lines N] [--follow|--no-follow]
|
|
Show the daemon log (defaults to tail -n 100 -f)
|
|
EOF
|
|
}
|
|
|
|
ensure_home() {
|
|
mkdir -p "${HERMES_HOME}" "${DEFAULT_STATE_DIR}"
|
|
}
|
|
|
|
_load_repo_dotenv_preserving_env() {
|
|
local env_file="${REPO_ROOT}/.env"
|
|
[[ -f "${env_file}" ]] || return 0
|
|
|
|
local -a preserved=()
|
|
local line key value
|
|
while IFS= read -r line || [[ -n "${line}" ]]; do
|
|
line="${line#${line%%[![:space:]]*}}"
|
|
[[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue
|
|
key="${line%%=*}"
|
|
key="${key#export }"
|
|
key="${key//[[:space:]]/}"
|
|
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
|
if [[ -n "${!key+x}" ]]; then
|
|
value="${!key}"
|
|
preserved+=("${key}=${value}")
|
|
fi
|
|
done < "${env_file}"
|
|
|
|
set -a
|
|
# shellcheck source=/dev/null
|
|
source "${env_file}"
|
|
set +a
|
|
|
|
local assignment
|
|
if [[ ${#preserved[@]} -gt 0 ]]; then
|
|
for assignment in "${preserved[@]}"; do
|
|
export "${assignment}"
|
|
done
|
|
fi
|
|
}
|
|
|
|
_find_python() {
|
|
if [[ -n "${HERMES_WEBUI_PYTHON:-}" ]]; then
|
|
printf '%s\n' "${HERMES_WEBUI_PYTHON}"
|
|
elif command -v python3 >/dev/null 2>&1; then
|
|
command -v python3
|
|
elif command -v python >/dev/null 2>&1; then
|
|
command -v python
|
|
else
|
|
echo "[ctl] Python 3 is required to run bootstrap.py" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
_parse_launch_binding() {
|
|
CTL_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
|
|
CTL_PORT="${HERMES_WEBUI_PORT:-8787}"
|
|
local arg next_is_host=0 saw_port=0
|
|
for arg in "$@"; do
|
|
if (( next_is_host )); then
|
|
CTL_HOST="${arg}"
|
|
next_is_host=0
|
|
continue
|
|
fi
|
|
case "${arg}" in
|
|
--host)
|
|
next_is_host=1
|
|
;;
|
|
--host=*)
|
|
CTL_HOST="${arg#--host=}"
|
|
;;
|
|
--*)
|
|
;;
|
|
*)
|
|
if (( ! saw_port )) && [[ "${arg}" =~ ^[0-9]+$ ]]; then
|
|
CTL_PORT="${arg}"
|
|
saw_port=1
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
_build_bootstrap_args() {
|
|
CTL_BOOTSTRAP_ARGS=()
|
|
local arg next_is_host=0 saw_port=0
|
|
for arg in "$@"; do
|
|
if (( next_is_host )); then
|
|
next_is_host=0
|
|
continue
|
|
fi
|
|
case "${arg}" in
|
|
--host)
|
|
next_is_host=1
|
|
;;
|
|
--host=*)
|
|
;;
|
|
--*)
|
|
CTL_BOOTSTRAP_ARGS+=("${arg}")
|
|
;;
|
|
*)
|
|
if (( ! saw_port )) && [[ "${arg}" =~ ^[0-9]+$ ]]; then
|
|
saw_port=1
|
|
else
|
|
CTL_BOOTSTRAP_ARGS+=("${arg}")
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
_write_state() {
|
|
local pid="$1" host="$2" port="$3" python_exe="${4:-}"
|
|
local state_dir="${HERMES_WEBUI_STATE_DIR:-${DEFAULT_STATE_DIR}}"
|
|
{
|
|
printf 'PID=%q\n' "${pid}"
|
|
printf 'REPO_ROOT=%q\n' "${REPO_ROOT}"
|
|
printf 'PYTHON_EXE=%q\n' "${python_exe}"
|
|
printf 'HOST=%q\n' "${host}"
|
|
printf 'PORT=%q\n' "${port}"
|
|
printf 'LOG_FILE=%q\n' "${LOG_FILE}"
|
|
printf 'STATE_DIR=%q\n' "${state_dir}"
|
|
printf 'STARTED_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
} > "${STATE_FILE}"
|
|
}
|
|
|
|
_load_state_if_present() {
|
|
if [[ -f "${STATE_FILE}" ]]; then
|
|
# shellcheck source=/dev/null
|
|
source "${STATE_FILE}"
|
|
fi
|
|
}
|
|
|
|
_pid_from_file() {
|
|
[[ -f "${PID_FILE}" ]] || return 1
|
|
local pid
|
|
pid="$(tr -d '[:space:]' < "${PID_FILE}")"
|
|
[[ "${pid}" =~ ^[0-9]+$ ]] || return 1
|
|
printf '%s\n' "${pid}"
|
|
}
|
|
|
|
_is_alive() {
|
|
local pid="$1"
|
|
kill -0 "${pid}" >/dev/null 2>&1
|
|
}
|
|
|
|
_proc_args() {
|
|
local pid="$1"
|
|
ps -p "${pid}" -o args= 2>/dev/null || true
|
|
}
|
|
|
|
_is_owned_webui_pid() {
|
|
local pid="$1" args state_repo="" state_python=""
|
|
[[ -f "${STATE_FILE}" ]] || return 1
|
|
_load_state_if_present
|
|
state_repo="${REPO_ROOT:-}"
|
|
state_python="${PYTHON_EXE:-}"
|
|
[[ "${state_repo}" == "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ]] || return 1
|
|
args="$(_proc_args "${pid}")"
|
|
[[ -n "${args}" ]] || return 1
|
|
[[ "${args}" == *"${state_repo}/bootstrap.py"* || "${args}" == *"${state_repo}/server.py"* || "${args}" == *"${state_repo}/start.sh"* || ( -n "${state_python}" && "${args}" == *"${state_python}"* ) ]]
|
|
}
|
|
|
|
_current_pid() {
|
|
local pid
|
|
pid="$(_pid_from_file)" || return 1
|
|
if _is_alive "${pid}" && _is_owned_webui_pid "${pid}"; then
|
|
printf '%s\n' "${pid}"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
_clear_stale_pid() {
|
|
if [[ -f "${PID_FILE}" ]]; then
|
|
rm -f "${PID_FILE}" "${STATE_FILE}"
|
|
echo "[ctl] Removed stale PID file: ${PID_FILE}"
|
|
fi
|
|
}
|
|
|
|
start_cmd() {
|
|
ensure_home
|
|
_load_repo_dotenv_preserving_env
|
|
export HERMES_WEBUI_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${DEFAULT_STATE_DIR}}"
|
|
mkdir -p "${HERMES_WEBUI_STATE_DIR}"
|
|
_parse_launch_binding "$@"
|
|
_build_bootstrap_args "$@"
|
|
export HERMES_WEBUI_HOST="${CTL_HOST}"
|
|
export HERMES_WEBUI_PORT="${CTL_PORT}"
|
|
|
|
local existing_pid
|
|
if existing_pid="$(_current_pid 2>/dev/null)"; then
|
|
echo "[ctl] Hermes WebUI is already running (PID ${existing_pid})"
|
|
return 0
|
|
fi
|
|
_clear_stale_pid >/dev/null 2>&1 || true
|
|
|
|
local python_exe pid
|
|
python_exe="$(_find_python)"
|
|
: >> "${LOG_FILE}"
|
|
(
|
|
cd "${REPO_ROOT}"
|
|
exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"}
|
|
) >> "${LOG_FILE}" 2>&1 &
|
|
pid=$!
|
|
|
|
printf '%s\n' "${pid}" > "${PID_FILE}"
|
|
_write_state "${pid}" "${CTL_HOST}" "${CTL_PORT}" "${python_exe}"
|
|
sleep 0.15
|
|
if ! _is_alive "${pid}"; then
|
|
echo "[ctl] Hermes WebUI failed to stay running. Log: ${LOG_FILE}" >&2
|
|
rm -f "${PID_FILE}" "${STATE_FILE}"
|
|
return 1
|
|
fi
|
|
echo "[ctl] Started Hermes WebUI (PID ${pid})"
|
|
echo "[ctl] Bound: ${CTL_HOST}:${CTL_PORT}"
|
|
echo "[ctl] Log: ${LOG_FILE}"
|
|
}
|
|
|
|
stop_cmd() {
|
|
ensure_home
|
|
local pid
|
|
if ! pid="$(_pid_from_file 2>/dev/null)"; then
|
|
echo "[ctl] Hermes WebUI is stopped"
|
|
rm -f "${PID_FILE}" "${STATE_FILE}"
|
|
return 0
|
|
fi
|
|
|
|
if ! _is_alive "${pid}" || ! _is_owned_webui_pid "${pid}"; then
|
|
_clear_stale_pid
|
|
return 0
|
|
fi
|
|
|
|
echo "[ctl] Stopping Hermes WebUI (PID ${pid})"
|
|
kill "${pid}" >/dev/null 2>&1 || true
|
|
local i
|
|
for i in {1..50}; do
|
|
if ! _is_alive "${pid}"; then
|
|
rm -f "${PID_FILE}" "${STATE_FILE}"
|
|
echo "[ctl] Stopped"
|
|
return 0
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
|
|
echo "[ctl] Process did not exit after SIGTERM; sending SIGKILL" >&2
|
|
kill -KILL "${pid}" >/dev/null 2>&1 || true
|
|
rm -f "${PID_FILE}" "${STATE_FILE}"
|
|
}
|
|
|
|
_health_line() {
|
|
local host="$1" port="$2" url result
|
|
url="http://${host}:${port}/health"
|
|
if command -v curl >/dev/null 2>&1; then
|
|
if result="$(curl -fsS --max-time 2 "${url}" 2>/dev/null)"; then
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
printf '%s' "${result}" | python3 -c 'import json,sys
|
|
try:
|
|
data=json.load(sys.stdin)
|
|
sessions=data.get("sessions", data.get("session_count", "?"))
|
|
active=data.get("active_streams", "?")
|
|
status=data.get("status", "ok")
|
|
print(f"ok ({sessions} sessions, {active} active streams)" if status == "ok" else status)
|
|
except Exception:
|
|
print("ok")'
|
|
else
|
|
echo "ok"
|
|
fi
|
|
else
|
|
echo "unreachable (${url})"
|
|
fi
|
|
else
|
|
echo "unknown (curl not found; ${url})"
|
|
fi
|
|
}
|
|
|
|
status_cmd() {
|
|
ensure_home
|
|
_load_state_if_present
|
|
local host="${HOST:-${HERMES_WEBUI_HOST:-127.0.0.1}}"
|
|
local port="${PORT:-${HERMES_WEBUI_PORT:-8787}}"
|
|
local log_path="${LOG_FILE}"
|
|
local pid uptime health
|
|
|
|
if pid="$(_current_pid 2>/dev/null)"; then
|
|
uptime="$(ps -p "${pid}" -o etime= 2>/dev/null | sed 's/^ *//' || true)"
|
|
health="$(_health_line "${host}" "${port}")"
|
|
echo "● hermes-webui — running"
|
|
echo " PID: ${pid}"
|
|
echo " Uptime: ${uptime:-unknown}"
|
|
echo " Bound: ${host}:${port}"
|
|
echo " Log: ${log_path}"
|
|
echo " Health: ${health}"
|
|
else
|
|
[[ -f "${PID_FILE}" ]] && _clear_stale_pid >/dev/null 2>&1 || true
|
|
echo "● hermes-webui — stopped"
|
|
echo " PID: -"
|
|
echo " Bound: ${host}:${port}"
|
|
echo " Log: ${log_path}"
|
|
echo " Health: not checked"
|
|
fi
|
|
}
|
|
|
|
logs_cmd() {
|
|
ensure_home
|
|
local lines=100 follow=1
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--lines)
|
|
shift
|
|
lines="${1:-}"
|
|
[[ "${lines}" =~ ^[0-9]+$ ]] || { echo "[ctl] --lines requires a number" >&2; return 2; }
|
|
;;
|
|
--lines=*)
|
|
lines="${1#--lines=}"
|
|
[[ "${lines}" =~ ^[0-9]+$ ]] || { echo "[ctl] --lines requires a number" >&2; return 2; }
|
|
;;
|
|
--follow|-f)
|
|
follow=1
|
|
;;
|
|
--no-follow)
|
|
follow=0
|
|
;;
|
|
*)
|
|
echo "[ctl] Unknown logs option: $1" >&2
|
|
return 2
|
|
;;
|
|
esac
|
|
shift
|
|
done
|
|
touch "${LOG_FILE}"
|
|
if (( follow )); then
|
|
tail -n "${lines}" -f "${LOG_FILE}"
|
|
else
|
|
tail -n "${lines}" "${LOG_FILE}"
|
|
fi
|
|
}
|
|
|
|
cmd="${1:-}"
|
|
if [[ $# -gt 0 ]]; then
|
|
shift
|
|
fi
|
|
|
|
case "${cmd}" in
|
|
start) start_cmd "$@" ;;
|
|
stop) stop_cmd ;;
|
|
restart) stop_cmd; start_cmd "$@" ;;
|
|
status) status_cmd ;;
|
|
logs) logs_cmd "$@" ;;
|
|
-h|--help|help|"") usage ;;
|
|
*) echo "[ctl] Unknown command: ${cmd}" >&2; usage >&2; exit 2 ;;
|
|
esac
|