audit echo vs log across all scripts — outcomes always visible, verbose for per-item loops
This commit is contained in:
+312
@@ -0,0 +1,312 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# =========================== Intermediate Sync Maintenance ====================================
|
||||
# ==============================================================================================
|
||||
# 4-hour orchestrator — arr library reconciliation, artwork fetching, and optional rsync.
|
||||
# Schedule: 0 */4 * * * (every 4 hours)
|
||||
#
|
||||
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
|
||||
# 1. arr_sync.sh — sync Lidarr/Sonarr/Radarr libraries across all nodes
|
||||
# 2. Rsync window (optional) — INTERMEDIATE_SYNC_SHARES, if any configured
|
||||
# 3. INTERMEDIATE_MAINTENANCE_SCRIPTS — artwork fetch and any future 4-hour jobs
|
||||
#
|
||||
# ── WHY A SEPARATE ORCHESTRATOR ───────────────────────────────────────────────────────────────
|
||||
# arr libraries need to converge more frequently than once a day. If a remote node adds
|
||||
# something at 2am, the next daily window is 23 hours away — remote arrs search for content
|
||||
# they don't know is already owned. Running every 4 hours closes that gap.
|
||||
#
|
||||
# lidarr_missing_art.sh is idempotent — skips existing files, runs fast after initial fill.
|
||||
# Pairing it here means artwork catches up within 4 hours of a new album landing.
|
||||
#
|
||||
# Rsync is optional — INTERMEDIATE_SYNC_SHARES empty by default. Add shares to the config
|
||||
# if a subset of data needs mid-day propagation (e.g. watch state, metadata). Full media
|
||||
# share sync stays in the daily window.
|
||||
#
|
||||
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
|
||||
# detect_hosts() sets MY_ID and aliases HOST*_INTERMEDIATE_SYNC_SHARES → INTERMEDIATE_SYNC_SHARES.
|
||||
# Each server can have a different set of mid-day shares — configure in host*.conf.
|
||||
# Each script in INTERMEDIATE_MAINTENANCE_SCRIPTS handles its own host logic.
|
||||
#
|
||||
# ── DRIVE TEMP HANDLING ───────────────────────────────────────────────────────────────────────
|
||||
# Same as daily_sync_maintenance.sh:
|
||||
# exit 1 = temp WARN — skip this share, continue to next
|
||||
# exit 2 = temp CRITICAL — abort ALL remaining syncs in this window
|
||||
#
|
||||
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
|
||||
# Root check — scripts called here require root
|
||||
# acquire_lock — prevents concurrent intermediate windows
|
||||
# check_connectivity — verified before any rsync (skipped if no shares)
|
||||
# check_remote_rootfs — aborts rsync if remote rootfs nearly full
|
||||
# Non-fatal jobs — a failed arr_sync warns but does not block rsync or artwork fetch
|
||||
# Silent on success — runs 4x/day, only failures warrant notification
|
||||
#
|
||||
# ── CONFIGURATION ─────────────────────────────────────────────────────────────────────────────
|
||||
# host*.conf: HOST*_INTERMEDIATE_SYNC_SHARES — shares synced mid-day (empty = rsync skipped)
|
||||
# master.conf: INTERMEDIATE_RSYNC_ENABLED — enable/disable rsync section (default: true)
|
||||
# master.conf: INTERMEDIATE_MAINTENANCE_SCRIPTS — jobs run after rsync
|
||||
# master.conf: ARR_SYNC_ENABLED — toggle inside arr_sync.sh
|
||||
#
|
||||
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# intermediate_sync_maintenance.sh — normal run
|
||||
# intermediate_sync_maintenance.sh --dry-run — preview without changes
|
||||
# intermediate_sync_maintenance.sh --log — verbose per-job output
|
||||
# intermediate_sync_maintenance.sh --status — show configured shares/jobs and exit
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
|
||||
SCRIPTS_ROOT="$SCRIPT_DIR/.."
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
error "Must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
platform_require_cmd \
|
||||
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
|
||||
"" "" \
|
||||
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
|
||||
|
||||
acquire_lock
|
||||
|
||||
detect_hosts
|
||||
resolve_remote_ip
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
|
||||
# ── Helper — run a maintenance job, track pass/fail ───────────────────────────────────────────
|
||||
run_job() {
|
||||
local script_entry="$1"
|
||||
local extra_dry=""
|
||||
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
|
||||
|
||||
read -r -a script_args <<< "$script_entry"
|
||||
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
|
||||
local script_name
|
||||
script_name=$(basename "${script_args[0]}")
|
||||
local extra_args=("${script_args[@]:1}")
|
||||
|
||||
if [[ ! -f "$script_path" ]]; then
|
||||
error "$script_name — not found at $script_path"
|
||||
JOB_FAIL+=("$script_name")
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Running: $script_name ${extra_args[*]}"
|
||||
# shellcheck disable=SC2086
|
||||
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
|
||||
log "$script_name — done ✅"
|
||||
JOB_PASS+=("$script_name ${extra_args[*]}")
|
||||
else
|
||||
warn "$script_name — failed (exit $?)"
|
||||
JOB_FAIL+=("$script_name ${extra_args[*]}")
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Status ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$SHOW_STATUS" == true ]]; then
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC STATUS ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
|
||||
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
|
||||
echo "$ICON_SYNC Interm. enabled: ${INTERMEDIATE_RSYNC_ENABLED:-true}"
|
||||
echo "$ICON_GEAR Arr sync: ${ARR_SYNC_ENABLED:-true}"
|
||||
echo ""
|
||||
echo "━━━ Intermediate Sync Shares ━━━"
|
||||
if [[ ${#INTERMEDIATE_SYNC_SHARES[@]} -eq 0 ]]; then
|
||||
echo " None configured — add to INTERMEDIATE_SYNC_SHARES in master.conf to enable"
|
||||
else
|
||||
for share in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
|
||||
[[ -n "$share" ]] && echo " $ICON_SYNC $(basename "$share") ($share)"
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
echo "━━━ Intermediate Maintenance Scripts ━━━"
|
||||
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
|
||||
echo " None configured"
|
||||
else
|
||||
for entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
|
||||
[[ -n "$entry" ]] && echo " $ICON_GEAR ${entry##*/}"
|
||||
done
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WINDOW_START=$(date +%s)
|
||||
JOB_PASS=()
|
||||
JOB_FAIL=()
|
||||
PASS=()
|
||||
FAIL=()
|
||||
SHARE_TIMES=()
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Intermediate Sync — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Arr Sync ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Arr Sync ━━━"
|
||||
|
||||
ARR_SYNC_SCRIPT="$SCRIPTS_ROOT/Media/arr_sync.sh"
|
||||
if [[ "${ARR_SYNC_ENABLED:-true}" != "true" ]]; then
|
||||
echo "ARR_SYNC_ENABLED=false — skipping"
|
||||
elif [[ ! -f "$ARR_SYNC_SCRIPT" ]]; then
|
||||
warn "arr_sync.sh not found at $ARR_SYNC_SCRIPT — skipping"
|
||||
JOB_FAIL+=("arr_sync.sh")
|
||||
else
|
||||
_arr_sync_args=()
|
||||
[[ "$DRY_RUN" == true ]] && _arr_sync_args+=("--dry-run")
|
||||
if bash "$ARR_SYNC_SCRIPT" "${_arr_sync_args[@]}"; then
|
||||
echo "Arr sync complete ✅"
|
||||
JOB_PASS+=("arr_sync.sh")
|
||||
else
|
||||
warn "Arr sync completed with errors — continuing"
|
||||
JOB_FAIL+=("arr_sync.sh")
|
||||
fi
|
||||
unset _arr_sync_args
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Rsync (optional) ━━━
|
||||
# ==============================================================================================
|
||||
SHARE_COUNT=${#INTERMEDIATE_SYNC_SHARES[@]}
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Mid-day Share Sync — $SHARE_COUNT share(s) ━━━"
|
||||
|
||||
TOTAL_START=$(date +%s)
|
||||
SHARE_INDEX=0
|
||||
ABORT_ALL_SYNCS=false
|
||||
|
||||
if [[ "$SHARE_COUNT" -eq 0 ]]; then
|
||||
echo "No INTERMEDIATE_SYNC_SHARES configured — skipping"
|
||||
echo "Add shares to INTERMEDIATE_SYNC_SHARES in master.conf to enable mid-day sync"
|
||||
elif ! check_rsync_enabled "INTERMEDIATE"; then
|
||||
warn "Intermediate rsync disabled — skipping all $SHARE_COUNT share sync(s)"
|
||||
else
|
||||
check_connectivity
|
||||
check_remote_rootfs
|
||||
|
||||
RSYNC_DRY=""
|
||||
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
|
||||
|
||||
for SHARE in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
|
||||
(( SHARE_INDEX++ ))
|
||||
SHARE_NAME=$(basename "$SHARE")
|
||||
SHARE_START=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Share $SHARE_INDEX/$SHARE_COUNT: $SHARE_NAME ━━━"
|
||||
|
||||
if [[ "$ABORT_ALL_SYNCS" == true ]]; then
|
||||
warn "$SHARE_NAME — skipped (drive temps CRITICAL earlier in window)"
|
||||
FAIL+=("$SHARE_NAME:temp-critical")
|
||||
continue
|
||||
fi
|
||||
|
||||
bash "$RSYNC_SCRIPT" "$SHARE" $RSYNC_DRY
|
||||
RSYNC_EXIT=$?
|
||||
|
||||
SHARE_TIMES+=("$SHARE_NAME:$(( $(date +%s) - SHARE_START ))")
|
||||
|
||||
case "$RSYNC_EXIT" in
|
||||
0)
|
||||
PASS+=("$SHARE_NAME")
|
||||
log "$SHARE_NAME — done ✅"
|
||||
;;
|
||||
1)
|
||||
FAIL+=("$SHARE_NAME:temp-warn")
|
||||
warn "$SHARE_NAME skipped — drive temps too high"
|
||||
;;
|
||||
2)
|
||||
FAIL+=("$SHARE_NAME:temp-critical")
|
||||
ABORT_ALL_SYNCS=true
|
||||
error "$SHARE_NAME aborted — drive temps CRITICAL, stopping all remaining syncs"
|
||||
notify "Intermediate sync aborted on $(hostname) ($MY_ID) — drive temps CRITICAL during $SHARE_NAME" \
|
||||
"Intermediate Sync" "warning"
|
||||
;;
|
||||
*)
|
||||
FAIL+=("$SHARE_NAME")
|
||||
error "$SHARE_NAME failed (exit $RSYNC_EXIT) — continuing to next share"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
TOTAL_END=$(date +%s)
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Maintenance Jobs ━━━
|
||||
# ==============================================================================================
|
||||
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Maintenance Jobs ━━━"
|
||||
for script_entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
|
||||
[[ -z "$script_entry" ]] && continue
|
||||
echo ""
|
||||
run_job "$script_entry"
|
||||
done
|
||||
fi
|
||||
|
||||
WINDOW_END=$(date +%s)
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Summary ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC SUMMARY ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S') → $(date -d @"$WINDOW_END" '+%H:%M:%S')"
|
||||
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
|
||||
echo ""
|
||||
|
||||
if [[ "$SHARE_COUNT" -gt 0 ]]; then
|
||||
echo "$ICON_SYNC Shares ($SHARE_COUNT):"
|
||||
for entry in "${SHARE_TIMES[@]}"; do
|
||||
sname="${entry%%:*}"
|
||||
sdur="${entry##*:}"
|
||||
if printf '%s\n' "${FAIL[@]}" | grep -q "^${sname}"; then
|
||||
echo " $ICON_ERROR $sname — $(format_duration "$sdur")"
|
||||
else
|
||||
echo " $ICON_DONE $sname — $(format_duration "$sdur")"
|
||||
fi
|
||||
done
|
||||
echo " Passed: ${#PASS[@]}/$SHARE_COUNT Failed: ${#FAIL[@]}/$SHARE_COUNT"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
|
||||
echo "$ICON_GEAR Jobs:"
|
||||
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
|
||||
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — no changes made"
|
||||
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
|
||||
echo "$ICON_DONE Status: all complete ✅ — ${#JOB_PASS[@]} job(s) run, ${#PASS[@]}/$SHARE_COUNT share(s) synced"
|
||||
else
|
||||
warn "Status: $TOTAL_FAIL failure(s)"
|
||||
notify "Intermediate sync failed on $(hostname) ($MY_ID) — shares: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
|
||||
"Intermediate Sync" "warning"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
|
||||
exit 0
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# =========================== Intermediate Sync Maintenance ====================================
|
||||
# ==============================================================================================
|
||||
# 4-hour orchestrator — arr library reconciliation, artwork fetching, and optional rsync.
|
||||
# Schedule: 0 */4 * * * (every 4 hours)
|
||||
#
|
||||
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
|
||||
# 1. conf_sync.sh --pull-only — refresh partner conf cache in RAM (/tmp/.vv/)
|
||||
# 2. arr_sync.sh — sync Lidarr/Sonarr/Radarr libraries across all nodes
|
||||
# 3. Rsync window (optional) — INTERMEDIATE_SYNC_SHARES, if any configured
|
||||
# 4. INTERMEDIATE_MAINTENANCE_SCRIPTS — artwork fetch and any future 4-hour jobs
|
||||
#
|
||||
# ── WHY A SEPARATE ORCHESTRATOR ───────────────────────────────────────────────────────────────
|
||||
# arr libraries need to converge more frequently than once a day. If a remote node adds
|
||||
# something at 2am, the next daily window is 23 hours away — remote arrs search for content
|
||||
# they don't know is already owned. Running every 4 hours closes that gap.
|
||||
#
|
||||
# lidarr_missing_art.sh is idempotent — skips existing files, runs fast after initial fill.
|
||||
# Pairing it here means artwork catches up within 4 hours of a new album landing.
|
||||
#
|
||||
# Rsync is optional — INTERMEDIATE_SYNC_SHARES empty by default. Add shares to the config
|
||||
# if a subset of data needs mid-day propagation (e.g. watch state, metadata). Full media
|
||||
# share sync stays in the daily window.
|
||||
#
|
||||
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
|
||||
# detect_hosts() sets MY_ID and aliases HOST*_INTERMEDIATE_SYNC_SHARES → INTERMEDIATE_SYNC_SHARES.
|
||||
# Each server can have a different set of mid-day shares — configure in host*.conf.
|
||||
# Each script in INTERMEDIATE_MAINTENANCE_SCRIPTS handles its own host logic.
|
||||
#
|
||||
# ── DRIVE TEMP HANDLING ───────────────────────────────────────────────────────────────────────
|
||||
# Same as daily_sync_maintenance.sh:
|
||||
# exit 1 = temp WARN — skip this share, continue to next
|
||||
# exit 2 = temp CRITICAL — abort ALL remaining syncs in this window
|
||||
#
|
||||
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
|
||||
# Root check — scripts called here require root
|
||||
# acquire_lock — prevents concurrent intermediate windows
|
||||
# check_connectivity — verified before any rsync (skipped if no shares)
|
||||
# check_remote_rootfs — aborts rsync if remote rootfs nearly full
|
||||
# Non-fatal jobs — a failed arr_sync warns but does not block rsync or artwork fetch
|
||||
# Silent on success — runs 4x/day, only failures warrant notification
|
||||
#
|
||||
# ── CONFIGURATION ─────────────────────────────────────────────────────────────────────────────
|
||||
# host*.conf: HOST*_INTERMEDIATE_SYNC_SHARES — shares synced mid-day (empty = rsync skipped)
|
||||
# master.conf: INTERMEDIATE_RSYNC_ENABLED — enable/disable rsync section (default: true)
|
||||
# master.conf: INTERMEDIATE_MAINTENANCE_SCRIPTS — jobs run after rsync
|
||||
# master.conf: ARR_SYNC_ENABLED — toggle inside arr_sync.sh
|
||||
#
|
||||
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# intermediate_sync_maintenance.sh — normal run
|
||||
# intermediate_sync_maintenance.sh --dry-run — preview without changes
|
||||
# intermediate_sync_maintenance.sh --log — verbose per-job output
|
||||
# intermediate_sync_maintenance.sh --status — show configured shares/jobs and exit
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
|
||||
SCRIPTS_ROOT="$SCRIPT_DIR/.."
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
error "Must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
platform_require_cmd \
|
||||
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
|
||||
"" "" \
|
||||
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
|
||||
|
||||
acquire_lock
|
||||
|
||||
detect_hosts
|
||||
resolve_remote_ip
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
|
||||
# ── Helper — run a maintenance job, track pass/fail ───────────────────────────────────────────
|
||||
run_job() {
|
||||
local script_entry="$1"
|
||||
local extra_dry=""
|
||||
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
|
||||
|
||||
read -r -a script_args <<< "$script_entry"
|
||||
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
|
||||
local script_name
|
||||
script_name=$(basename "${script_args[0]}")
|
||||
local extra_args=("${script_args[@]:1}")
|
||||
|
||||
if [[ ! -f "$script_path" ]]; then
|
||||
error "$script_name — not found at $script_path"
|
||||
JOB_FAIL+=("$script_name")
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Running: $script_name ${extra_args[*]}"
|
||||
# shellcheck disable=SC2086
|
||||
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
|
||||
log "$script_name — done ✅"
|
||||
JOB_PASS+=("$script_name ${extra_args[*]}")
|
||||
else
|
||||
warn "$script_name — failed (exit $?)"
|
||||
JOB_FAIL+=("$script_name ${extra_args[*]}")
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Status ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$SHOW_STATUS" == true ]]; then
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC STATUS ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
|
||||
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
|
||||
echo "$ICON_SYNC Interm. enabled: ${INTERMEDIATE_RSYNC_ENABLED:-true}"
|
||||
echo "$ICON_GEAR Arr sync: ${ARR_SYNC_ENABLED:-true}"
|
||||
echo ""
|
||||
echo "━━━ Intermediate Sync Shares ━━━"
|
||||
if [[ ${#INTERMEDIATE_SYNC_SHARES[@]} -eq 0 ]]; then
|
||||
echo " None configured — add to INTERMEDIATE_SYNC_SHARES in master.conf to enable"
|
||||
else
|
||||
for share in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
|
||||
[[ -n "$share" ]] && echo " $ICON_SYNC $(basename "$share") ($share)"
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
echo "━━━ Intermediate Maintenance Scripts ━━━"
|
||||
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
|
||||
echo " None configured"
|
||||
else
|
||||
for entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
|
||||
[[ -n "$entry" ]] && echo " $ICON_GEAR ${entry##*/}"
|
||||
done
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WINDOW_START=$(date +%s)
|
||||
JOB_PASS=()
|
||||
JOB_FAIL=()
|
||||
PASS=()
|
||||
FAIL=()
|
||||
SHARE_TIMES=()
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Intermediate Sync — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Conf Pull ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Conf Pull ━━━"
|
||||
|
||||
CONF_SYNC_SCRIPT="$SCRIPTS_ROOT/System_Essentials/conf_sync.sh"
|
||||
if [[ ! -f "$CONF_SYNC_SCRIPT" ]]; then
|
||||
warn "conf_sync.sh not found — skipping partner conf refresh"
|
||||
else
|
||||
_conf_args=("--pull-only")
|
||||
[[ "$DRY_RUN" == true ]] && _conf_args+=("--dry-run")
|
||||
if bash "$CONF_SYNC_SCRIPT" "${_conf_args[@]}"; then
|
||||
log "Partner conf cache refreshed ✅"
|
||||
JOB_PASS+=("conf_sync.sh --pull-only")
|
||||
else
|
||||
warn "Partner conf pull failed — cache may be stale"
|
||||
JOB_FAIL+=("conf_sync.sh --pull-only")
|
||||
fi
|
||||
unset _conf_args
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Arr Sync ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Arr Sync ━━━"
|
||||
|
||||
ARR_SYNC_SCRIPT="$SCRIPTS_ROOT/Media/arr_sync.sh"
|
||||
if [[ "${ARR_SYNC_ENABLED:-true}" != "true" ]]; then
|
||||
echo "ARR_SYNC_ENABLED=false — skipping"
|
||||
elif [[ ! -f "$ARR_SYNC_SCRIPT" ]]; then
|
||||
warn "arr_sync.sh not found at $ARR_SYNC_SCRIPT — skipping"
|
||||
JOB_FAIL+=("arr_sync.sh")
|
||||
else
|
||||
_arr_sync_args=()
|
||||
[[ "$DRY_RUN" == true ]] && _arr_sync_args+=("--dry-run")
|
||||
if bash "$ARR_SYNC_SCRIPT" "${_arr_sync_args[@]}"; then
|
||||
echo "Arr sync complete ✅"
|
||||
JOB_PASS+=("arr_sync.sh")
|
||||
else
|
||||
warn "Arr sync completed with errors — continuing"
|
||||
JOB_FAIL+=("arr_sync.sh")
|
||||
fi
|
||||
unset _arr_sync_args
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Rsync (optional) ━━━
|
||||
# ==============================================================================================
|
||||
SHARE_COUNT=${#INTERMEDIATE_SYNC_SHARES[@]}
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Mid-day Share Sync — $SHARE_COUNT share(s) ━━━"
|
||||
|
||||
TOTAL_START=$(date +%s)
|
||||
SHARE_INDEX=0
|
||||
ABORT_ALL_SYNCS=false
|
||||
|
||||
if [[ "$SHARE_COUNT" -eq 0 ]]; then
|
||||
echo "No INTERMEDIATE_SYNC_SHARES configured — skipping"
|
||||
echo "Add shares to INTERMEDIATE_SYNC_SHARES in master.conf to enable mid-day sync"
|
||||
elif ! check_rsync_enabled "INTERMEDIATE"; then
|
||||
warn "Intermediate rsync disabled — skipping all $SHARE_COUNT share sync(s)"
|
||||
else
|
||||
check_connectivity
|
||||
check_remote_rootfs
|
||||
|
||||
RSYNC_DRY=""
|
||||
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
|
||||
|
||||
for SHARE in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
|
||||
(( SHARE_INDEX++ ))
|
||||
SHARE_NAME=$(basename "$SHARE")
|
||||
SHARE_START=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Share $SHARE_INDEX/$SHARE_COUNT: $SHARE_NAME ━━━"
|
||||
|
||||
if [[ "$ABORT_ALL_SYNCS" == true ]]; then
|
||||
warn "$SHARE_NAME — skipped (drive temps CRITICAL earlier in window)"
|
||||
FAIL+=("$SHARE_NAME:temp-critical")
|
||||
continue
|
||||
fi
|
||||
|
||||
bash "$RSYNC_SCRIPT" "$SHARE" $RSYNC_DRY
|
||||
RSYNC_EXIT=$?
|
||||
|
||||
SHARE_TIMES+=("$SHARE_NAME:$(( $(date +%s) - SHARE_START ))")
|
||||
|
||||
case "$RSYNC_EXIT" in
|
||||
0)
|
||||
PASS+=("$SHARE_NAME")
|
||||
log "$SHARE_NAME — done ✅"
|
||||
;;
|
||||
1)
|
||||
FAIL+=("$SHARE_NAME:temp-warn")
|
||||
warn "$SHARE_NAME skipped — drive temps too high"
|
||||
;;
|
||||
2)
|
||||
FAIL+=("$SHARE_NAME:temp-critical")
|
||||
ABORT_ALL_SYNCS=true
|
||||
error "$SHARE_NAME aborted — drive temps CRITICAL, stopping all remaining syncs"
|
||||
notify "Intermediate sync aborted on $(hostname) ($MY_ID) — drive temps CRITICAL during $SHARE_NAME" \
|
||||
"Intermediate Sync" "warning"
|
||||
;;
|
||||
*)
|
||||
FAIL+=("$SHARE_NAME")
|
||||
error "$SHARE_NAME failed (exit $RSYNC_EXIT) — continuing to next share"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
TOTAL_END=$(date +%s)
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Maintenance Jobs ━━━
|
||||
# ==============================================================================================
|
||||
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Maintenance Jobs ━━━"
|
||||
for script_entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
|
||||
[[ -z "$script_entry" ]] && continue
|
||||
echo ""
|
||||
run_job "$script_entry"
|
||||
done
|
||||
fi
|
||||
|
||||
WINDOW_END=$(date +%s)
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Summary ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC SUMMARY ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S') → $(date -d @"$WINDOW_END" '+%H:%M:%S')"
|
||||
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
|
||||
echo ""
|
||||
|
||||
if [[ "$SHARE_COUNT" -gt 0 ]]; then
|
||||
echo "$ICON_SYNC Shares ($SHARE_COUNT):"
|
||||
for entry in "${SHARE_TIMES[@]}"; do
|
||||
sname="${entry%%:*}"
|
||||
sdur="${entry##*:}"
|
||||
if printf '%s\n' "${FAIL[@]}" | grep -q "^${sname}"; then
|
||||
echo " $ICON_ERROR $sname — $(format_duration "$sdur")"
|
||||
else
|
||||
echo " $ICON_DONE $sname — $(format_duration "$sdur")"
|
||||
fi
|
||||
done
|
||||
echo " Passed: ${#PASS[@]}/$SHARE_COUNT Failed: ${#FAIL[@]}/$SHARE_COUNT"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
|
||||
echo "$ICON_GEAR Jobs:"
|
||||
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
|
||||
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — no changes made"
|
||||
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
|
||||
echo "$ICON_DONE Status: all complete ✅ — ${#JOB_PASS[@]} job(s) run, ${#PASS[@]}/$SHARE_COUNT share(s) synced"
|
||||
else
|
||||
warn "Status: $TOTAL_FAIL failure(s)"
|
||||
notify "Intermediate sync failed on $(hostname) ($MY_ID) — shares: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
|
||||
"Intermediate Sync" "warning"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
|
||||
exit 0
|
||||
+1773
File diff suppressed because it is too large
Load Diff
+1555
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: feedback_dev_vs_prod
|
||||
description: User had a bad week where dev and production folders got mixed up — always confirm we are working in the single authoritative workspace
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: feedback
|
||||
originSessionId: d6d51683-5f05-41b4-ad5d-afbe06985ad9
|
||||
---
|
||||
|
||||
Always work exclusively in `/boot/config/plugins/varaverk` (the workspace). Never touch `/mnt/user/Important Shit/Git/Development/Varaverk` — that is a stale dev folder.
|
||||
|
||||
**Why:** User spent a week dealing with confusion between dev folder, installed dir, and workspace all drifting out of sync. This caused significant lost work and frustration. The `.plg` now symlinks the installed location directly to the workspace, so there is only one copy.
|
||||
|
||||
**How to apply:** If asked to edit any Varaverk file, always use the path under `/boot/config/plugins/varaverk/`. If the dev folder ever comes up, flag that it is stale and should be ignored.
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
|
||||
$_action = trim($_GET['action'] ?? '');
|
||||
if ($_action === 'refresh_remote') {
|
||||
$host = strtolower(trim($_GET['host'] ?? ''));
|
||||
if (!preg_match('/^host\d+$/', $host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
|
||||
}
|
||||
$script = SCRIPTS_DIR . '/Tools/remote_arr_cache_writer.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit;
|
||||
}
|
||||
set_time_limit(30);
|
||||
$out = []; $exit = 0;
|
||||
exec('bash ' . escapeshellarg($script) . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
|
||||
|
||||
$cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json';
|
||||
$node = null;
|
||||
if (file_exists($cacheFile)) {
|
||||
$node = json_decode(file_get_contents($cacheFile), true) ?: null;
|
||||
if ($node) {
|
||||
$node['cached'] = true;
|
||||
$node['cache_age'] = time() - (int)filemtime($cacheFile);
|
||||
}
|
||||
}
|
||||
// Bust the main arrs cache so next poll gets fresh data
|
||||
@unlink(VV_CACHE_DIR . '/arrs.json');
|
||||
echo json_encode(['ok' => $exit === 0, 'node' => $node, 'output' => implode("\n", $out)]);
|
||||
exit;
|
||||
}
|
||||
unset($_action);
|
||||
|
||||
// ── Normal data load ──────────────────────────────────────────────────────────
|
||||
$_vv_cached = vv_cache_read('arrs', 300);
|
||||
if ($_vv_cached !== null) { echo json_encode($_vv_cached); exit; }
|
||||
unset($_vv_cached);
|
||||
|
||||
require_once dirname(__DIR__) . '/include/arrs.php';
|
||||
echo json_encode(vv_arrs_all());
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
|
||||
$_action = trim($_GET['action'] ?? '');
|
||||
if ($_action === 'refresh_remote') {
|
||||
$host = strtolower(trim($_GET['host'] ?? ''));
|
||||
if (!preg_match('/^host\d+$/', $host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
|
||||
}
|
||||
$script = dirname(__DIR__) . '/tools/remote_arr_cache_writer.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit;
|
||||
}
|
||||
set_time_limit(30);
|
||||
$out = []; $exit = 0;
|
||||
exec('bash ' . escapeshellarg($script) . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
|
||||
|
||||
$cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json';
|
||||
$node = null;
|
||||
if (file_exists($cacheFile)) {
|
||||
$node = json_decode(file_get_contents($cacheFile), true) ?: null;
|
||||
if ($node) {
|
||||
$node['cached'] = true;
|
||||
$node['cache_age'] = time() - (int)filemtime($cacheFile);
|
||||
}
|
||||
}
|
||||
// Bust the main arrs cache so next poll gets fresh data
|
||||
@unlink(VV_CACHE_DIR . '/arrs.json');
|
||||
echo json_encode(['ok' => $exit === 0, 'node' => $node, 'output' => implode("\n", $out)]);
|
||||
exit;
|
||||
}
|
||||
unset($_action);
|
||||
|
||||
// ── Normal data load ──────────────────────────────────────────────────────────
|
||||
$_vv_cached = vv_cache_read('arrs', 300);
|
||||
if ($_vv_cached !== null) { echo json_encode($_vv_cached); exit; }
|
||||
unset($_vv_cached);
|
||||
|
||||
require_once dirname(__DIR__) . '/include/arrs.php';
|
||||
echo json_encode(vv_arrs_all());
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
// ── Manual remote refresh — runs remote_arr_cache_writer for one host ─────────
|
||||
$_action = trim($_GET['action'] ?? '');
|
||||
if ($_action === 'refresh_remote') {
|
||||
$host = strtolower(trim($_GET['host'] ?? ''));
|
||||
if (!preg_match('/^host\d+$/', $host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid host']); exit;
|
||||
}
|
||||
$script = dirname(__DIR__) . '/Tools/remote_arr_cache_writer.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit;
|
||||
}
|
||||
set_time_limit(30);
|
||||
$out = []; $exit = 0;
|
||||
exec('bash ' . escapeshellarg($script) . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit);
|
||||
|
||||
$cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json';
|
||||
$node = null;
|
||||
if (file_exists($cacheFile)) {
|
||||
$node = json_decode(file_get_contents($cacheFile), true) ?: null;
|
||||
if ($node) {
|
||||
$node['cached'] = true;
|
||||
$node['cache_age'] = time() - (int)filemtime($cacheFile);
|
||||
}
|
||||
}
|
||||
// Bust the main arrs cache so next poll gets fresh data
|
||||
@unlink(VV_CACHE_DIR . '/arrs.json');
|
||||
echo json_encode(['ok' => $exit === 0, 'node' => $node, 'output' => implode("\n", $out)]);
|
||||
exit;
|
||||
}
|
||||
unset($_action);
|
||||
|
||||
// ── Normal data load ──────────────────────────────────────────────────────────
|
||||
$_vv_cached = vv_cache_read('arrs', 300);
|
||||
if ($_vv_cached !== null) { echo json_encode($_vv_cached); exit; }
|
||||
unset($_vv_cached);
|
||||
|
||||
require_once dirname(__DIR__) . '/include/arrs.php';
|
||||
echo json_encode(vv_arrs_all());
|
||||
@@ -0,0 +1,251 @@
|
||||
<?xml version='1.0' standalone='yes'?>
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "varaverk">
|
||||
<!ENTITY author "gmer4lfe">
|
||||
<!ENTITY version "2026.05.31">
|
||||
<!ENTITY sha256 "d588470d6cc7f284601cb56039d5dfea6fb5bb1ee2c800657438a8edacafbd01">
|
||||
<!ENTITY launch "varaverk/monitor">
|
||||
<!ENTITY github "https://github.com/FailedProxy/Varaverk">
|
||||
<!ENTITY branch "main">
|
||||
<!ENTITY cfgdir "/boot/config/plugins/varaverk">
|
||||
<!ENTITY plugdir "/usr/local/emhttp/plugins/varaverk">
|
||||
<!ENTITY pkg "varaverk-&version;-noarch-1.txz">
|
||||
]>
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;"
|
||||
support="https://github.com/FailedProxy/Varaverk/issues"
|
||||
icon="/plugins/varaverk/icons/varaverk.png">
|
||||
|
||||
<CHANGES>
|
||||
###2026.05.31
|
||||
- Packaged release: web files now ship as a .txz that Unraid reinstalls to RAM on every boot
|
||||
- Survives reboots with zero manual steps (no symlink, no go script) — fixes plugin vanishing after OS upgrades
|
||||
- Scripts are git-cloned to appdata on first install; web files stay on flash (~200KB)
|
||||
- Updates handled in-UI (git pull); the plugin no longer pulls on every boot
|
||||
|
||||
###2026.05.30
|
||||
- First-run setup wizard: auto-detects hostname, creates master.conf + host conf from templates
|
||||
- Scheduler setup mode: after wizard, master.conf and host conf open sequentially with forced save flow
|
||||
- Partnership tab: Onboard button highlighted on arrival from wizard; disabled until partner is configured
|
||||
- HOST2 install paths: state-file pull, master.conf push detection, conf-only flow
|
||||
- Onboard Step 9: master.conf automatically pushed to all listed hosts on onboard completion
|
||||
- Graceful pre-onboard state: neutral banners instead of error warnings before SSH is configured
|
||||
- GitHub link in tab bar and Settings page; Community Apps support URL
|
||||
|
||||
###2026.05.28
|
||||
- Initial release: Monitor, Scheduler, Docker, Watchdog, Partnership, Fallback, Arrs tabs
|
||||
- Mutual container fallback with tiered escalation and strike-confirmed handback
|
||||
- Partnership lifecycle: onboard, offboard, transfer
|
||||
- Rsync profile system with per-share container stops and writeback
|
||||
- Watchdog: Tier 1 (explicit) + Tier 2 (global scan) container monitoring
|
||||
- master.conf push-on-save to all configured partners via SSH
|
||||
</CHANGES>
|
||||
|
||||
<!--
|
||||
── 1. Web files package (runs on every boot) ──────────────────────────────────
|
||||
unRAID runs `plugin install` on every .plg at boot, BEFORE the array mounts.
|
||||
The .txz lives on flash, so this installs the PHP/JS UI to RAM in time — and the
|
||||
disks_mounted event hooks inside it are then present when the array starts, so
|
||||
cron is rebuilt automatically. No Method attr = treated as "install" = every boot.
|
||||
|
||||
Download is skipped when the .txz is already on flash with a matching SHA256, so
|
||||
this also works offline / for a local install (copy the .txz into &cfgdir;).
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
<FILE Name="&cfgdir;/&pkg;" Run="/sbin/upgradepkg --install-new --reinstall">
|
||||
<URL>https://github.com/FailedProxy/Varaverk/releases/download/&version;/&pkg;</URL>
|
||||
<SHA256>&sha256;</SHA256>
|
||||
</FILE>
|
||||
|
||||
<!--
|
||||
── 2. Scripts bootstrap (first install only) ──────────────────────────────────
|
||||
Clones the repo directly into the plugin config dir on flash (/boot/config/plugins/varaverk).
|
||||
No array dependency — scripts live on flash (64GB NVMe) and are available at boot.
|
||||
Uses git init+fetch+reset so the clone works into the non-empty cfgdir (varaverk.cfg,
|
||||
varaverk-*.txz etc. are already there). Never auto-pulls — updates via the UI git pull.
|
||||
|
||||
Clone source priority:
|
||||
1. Gitea (internal) — reads settings from varaverk.cfg; detects container IP at runtime
|
||||
2. GitHub (public) — HTTPS fallback if Gitea is unreachable
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
<FILE Run="/bin/bash" Method="install">
|
||||
<INLINE>
|
||||
<![CDATA[
|
||||
#!/bin/bash
|
||||
PLUGIN="varaverk"
|
||||
CFG_DIR="/boot/config/plugins/$PLUGIN"
|
||||
CFG_FILE="$CFG_DIR/varaverk.cfg"
|
||||
GITHUB="https://github.com/FailedProxy/Varaverk"
|
||||
BRANCH="main"
|
||||
LOG="$CFG_DIR/install.log"
|
||||
|
||||
mkdir -p "$CFG_DIR"
|
||||
log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; }
|
||||
|
||||
# Scripts live in the plugin dir on flash — no array needed.
|
||||
SCRIPTS_DIR="$CFG_DIR"
|
||||
CONF_DIR="$SCRIPTS_DIR/Configurations"
|
||||
|
||||
# ── Boot device check ─────────────────────────────────────────────────────────
|
||||
# Warn if /boot is on a USB/removable device. Varaverk is designed for internal
|
||||
# NVMe/SSD boot — git repo + state files + data writes on USB will wear it out
|
||||
# fast and may run out of space. Install proceeds but user is warned.
|
||||
_boot_dev=$(df /boot --output=source 2>/dev/null | tail -1)
|
||||
_boot_base=$(lsblk -no pkname "$_boot_dev" 2>/dev/null || basename "${_boot_dev%[0-9p]*}")
|
||||
_removable=$(cat "/sys/block/${_boot_base}/removable" 2>/dev/null || echo "0")
|
||||
if [[ "$_removable" == "1" ]]; then
|
||||
log "WARNING: /boot is on a removable/USB device ($_boot_dev)"
|
||||
log "WARNING: Varaverk is designed for internal NVMe/SSD boot."
|
||||
log "WARNING: Running from USB risks drive wear and space exhaustion."
|
||||
log "WARNING: Strongly recommend migrating boot to an internal NVMe/SSD drive."
|
||||
fi
|
||||
unset _boot_dev _boot_base _removable
|
||||
|
||||
# Seed varaverk.cfg with defaults (SCRIPTS_DIR + Gitea settings) if not present.
|
||||
# Requires internal NVMe/SSD boot — scripts live on flash, available before array mounts.
|
||||
if [[ ! -f "$CFG_FILE" ]]; then
|
||||
cat > "$CFG_FILE" <<'CFGEOF'
|
||||
SCRIPTS_DIR="/boot/config/plugins/varaverk"
|
||||
GITEA_CONTAINER="Gitea"
|
||||
GITEA_REPO_PATH="FailedProxy/Varaverk.git"
|
||||
GITEA_SSH_KEY="/root/.ssh/unraid_gitea"
|
||||
SSH_PORT="221"
|
||||
CFGEOF
|
||||
log "seeded varaverk.cfg"
|
||||
fi
|
||||
|
||||
# Read Gitea settings from varaverk.cfg (allows override without editing .plg).
|
||||
_read_cfg() { grep -oP "(?<=^${1}=\")[^\"]*" "$CFG_FILE" 2>/dev/null || echo "${2}"; }
|
||||
GITEA_CONTAINER=$(_read_cfg GITEA_CONTAINER "Gitea")
|
||||
GITEA_REPO_PATH=$(_read_cfg GITEA_REPO_PATH "FailedProxy/Varaverk.git")
|
||||
GITEA_SSH_KEY=$(_read_cfg GITEA_SSH_KEY "/root/.ssh/unraid_gitea")
|
||||
SSH_PORT=$(_read_cfg SSH_PORT "221")
|
||||
|
||||
# Clone on first install only; never auto-pull (updates via the UI git pull).
|
||||
if [[ ! -d "$SCRIPTS_DIR/.git" ]]; then
|
||||
log "initialising repo in $SCRIPTS_DIR ($BRANCH)..."
|
||||
|
||||
# Locate Gitea: local container → local IP; else Tailscale; else fall back to GitHub.
|
||||
GITEA_IP=""
|
||||
if command -v docker >/dev/null 2>&1 && \
|
||||
docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${GITEA_CONTAINER}$"; then
|
||||
GITEA_IP=$(hostname -I | awk '{print $1}')
|
||||
log "Gitea running locally — using $GITEA_IP"
|
||||
elif command -v tailscale >/dev/null 2>&1; then
|
||||
# Try each known peer until we find one hosting Gitea
|
||||
while IFS= read -r peer_ip; do
|
||||
if ssh -i "$GITEA_SSH_KEY" -p "$SSH_PORT" \
|
||||
-o ConnectTimeout=3 -o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes "git@${peer_ip}" info 2>/dev/null | grep -q "varaverk\|Gitea\|gitea"; then
|
||||
GITEA_IP="$peer_ip"
|
||||
log "Gitea found on Tailscale peer $GITEA_IP"
|
||||
break
|
||||
fi
|
||||
done < <(tailscale status --json 2>/dev/null | \
|
||||
python3 -c "import json,sys; d=json.load(sys.stdin); \
|
||||
[print(v['TailscaleIPs'][0]) for v in d.get('Peer',{}).values() \
|
||||
if v.get('TailscaleIPs')]" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# init-in-place — git clone would fail because the dir already has files.
|
||||
git -C "$SCRIPTS_DIR" init >> "$LOG" 2>&1
|
||||
|
||||
CLONED=false
|
||||
if [[ -n "$GITEA_IP" && -f "$GITEA_SSH_KEY" ]]; then
|
||||
GITEA_URL="ssh://git@${GITEA_IP}:${SSH_PORT}/${GITEA_REPO_PATH}"
|
||||
log "trying Gitea: $GITEA_URL"
|
||||
git -C "$SCRIPTS_DIR" remote add origin "$GITEA_URL" >> "$LOG" 2>&1
|
||||
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT -o StrictHostKeyChecking=no" \
|
||||
GIT_TERMINAL_PROMPT=0 \
|
||||
git -C "$SCRIPTS_DIR" fetch --depth=1 origin "$BRANCH" >> "$LOG" 2>&1; then
|
||||
git -C "$SCRIPTS_DIR" reset --hard FETCH_HEAD >> "$LOG" 2>&1
|
||||
git -C "$SCRIPTS_DIR" branch -M "$BRANCH" >> "$LOG" 2>&1
|
||||
git -C "$SCRIPTS_DIR" branch --set-upstream-to=origin/"$BRANCH" "$BRANCH" >> "$LOG" 2>&1
|
||||
log "scripts installed from Gitea ($GITEA_IP)"
|
||||
CLONED=true
|
||||
else
|
||||
log "Gitea fetch failed — falling back to GitHub"
|
||||
git -C "$SCRIPTS_DIR" remote remove origin >> "$LOG" 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$CLONED" == false ]]; then
|
||||
log "trying GitHub: $GITHUB"
|
||||
git -C "$SCRIPTS_DIR" remote add origin "$GITHUB" >> "$LOG" 2>&1
|
||||
if GIT_TERMINAL_PROMPT=0 \
|
||||
git -C "$SCRIPTS_DIR" fetch --depth=1 origin "$BRANCH" >> "$LOG" 2>&1; then
|
||||
git -C "$SCRIPTS_DIR" reset --hard FETCH_HEAD >> "$LOG" 2>&1
|
||||
git -C "$SCRIPTS_DIR" branch -M "$BRANCH" >> "$LOG" 2>&1
|
||||
git -C "$SCRIPTS_DIR" branch --set-upstream-to=origin/"$BRANCH" "$BRANCH" >> "$LOG" 2>&1
|
||||
log "scripts installed from GitHub"
|
||||
CLONED=true
|
||||
else
|
||||
log "WARNING: both Gitea and GitHub failed — scripts not installed, retry when network is up"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log "repo present — leaving scripts untouched (update from the UI)"
|
||||
fi
|
||||
|
||||
# Seed master.conf from template if absent.
|
||||
mkdir -p "$CONF_DIR"
|
||||
if [[ ! -f "$CONF_DIR/master.conf" && -f "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" ]]; then
|
||||
cp "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" "$CONF_DIR/master.conf"
|
||||
log "seeded master.conf from template"
|
||||
fi
|
||||
log "install step complete"
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
|
||||
<!--
|
||||
── 3. Remove ───────────────────────────────────────────────────────────────────
|
||||
Stops background scripts, removes cron, the installed package, and flash config.
|
||||
Scripts/conf in appdata are left intact (delete manually for a full wipe).
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
<FILE Run="/bin/bash" Method="remove">
|
||||
<INLINE>
|
||||
<![CDATA[
|
||||
#!/bin/bash
|
||||
PLUGIN="varaverk"
|
||||
CFG_DIR="/boot/config/plugins/$PLUGIN"
|
||||
CFG_FILE="$CFG_DIR/varaverk.cfg"
|
||||
CRON_FILE="$CFG_DIR/varaverk.cron"
|
||||
WEB_DIR="/usr/local/emhttp/plugins/$PLUGIN"
|
||||
log() { echo "[Varaverk remove] $*"; }
|
||||
|
||||
[[ -f "$CFG_FILE" ]] && _sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
|
||||
SCRIPTS_DIR="${_sd:-$CFG_DIR}"
|
||||
|
||||
# Stop continuous background scripts.
|
||||
if [[ -f "$SCRIPTS_DIR/Fallback/fallback.sh" ]]; then
|
||||
bash "$SCRIPTS_DIR/Fallback/fallback.sh" --stop 2>/dev/null && log "fallback.sh stopped" || true
|
||||
fi
|
||||
pkill -f "run_job.sh" 2>/dev/null || true
|
||||
pkill -f "watchdog_orchestrator.sh" 2>/dev/null || true
|
||||
|
||||
# Remove cron entries.
|
||||
if [[ -f "$CRON_FILE" ]]; then
|
||||
rm -f "$CRON_FILE"
|
||||
/usr/local/sbin/update_cron 2>/dev/null || true
|
||||
log "cron removed"
|
||||
fi
|
||||
rm -f /etc/cron.d/varaverk
|
||||
|
||||
# Remove the installed package (and its RAM files).
|
||||
removepkg "$PLUGIN" 2>/dev/null || true
|
||||
[[ -L "$WEB_DIR" ]] && rm -f "$WEB_DIR"
|
||||
[[ -d "$WEB_DIR" ]] && rm -rf "$WEB_DIR"
|
||||
log "web files removed"
|
||||
|
||||
# Remove flash config (incl. cached .txz).
|
||||
rm -rf "$CFG_DIR"
|
||||
log "flash config removed"
|
||||
log "done — scripts/conf in $SCRIPTS_DIR preserved (delete manually for full wipe)"
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
|
||||
</PLUGIN>
|
||||
@@ -0,0 +1,256 @@
|
||||
<?xml version='1.0' standalone='yes'?>
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "varaverk">
|
||||
<!ENTITY author "gmer4lfe">
|
||||
<!ENTITY version "2026.05.31">
|
||||
<!ENTITY sha256 "d588470d6cc7f284601cb56039d5dfea6fb5bb1ee2c800657438a8edacafbd01">
|
||||
<!ENTITY launch "varaverk/monitor">
|
||||
<!ENTITY github "https://github.com/FailedProxy/Varaverk">
|
||||
<!ENTITY branch "main">
|
||||
<!ENTITY cfgdir "/boot/config/plugins/varaverk">
|
||||
<!ENTITY plugdir "/usr/local/emhttp/plugins/varaverk">
|
||||
<!ENTITY pkg "varaverk-&version;-noarch-1.txz">
|
||||
]>
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;"
|
||||
support="https://github.com/FailedProxy/Varaverk/issues"
|
||||
icon="/plugins/varaverk/icons/varaverk.png">
|
||||
|
||||
<CHANGES>
|
||||
###2026.05.31
|
||||
- Packaged release: web files now ship as a .txz that Unraid reinstalls to RAM on every boot
|
||||
- Survives reboots with zero manual steps (no symlink, no go script) — fixes plugin vanishing after OS upgrades
|
||||
- Scripts are git-cloned to appdata on first install; web files stay on flash (~200KB)
|
||||
- Updates handled in-UI (git pull); the plugin no longer pulls on every boot
|
||||
|
||||
###2026.05.30
|
||||
- First-run setup wizard: auto-detects hostname, creates master.conf + host conf from templates
|
||||
- Scheduler setup mode: after wizard, master.conf and host conf open sequentially with forced save flow
|
||||
- Partnership tab: Onboard button highlighted on arrival from wizard; disabled until partner is configured
|
||||
- HOST2 install paths: state-file pull, master.conf push detection, conf-only flow
|
||||
- Onboard Step 9: master.conf automatically pushed to all listed hosts on onboard completion
|
||||
- Graceful pre-onboard state: neutral banners instead of error warnings before SSH is configured
|
||||
- GitHub link in tab bar and Settings page; Community Apps support URL
|
||||
|
||||
###2026.05.28
|
||||
- Initial release: Monitor, Scheduler, Docker, Watchdog, Partnership, Fallback, Arrs tabs
|
||||
- Mutual container fallback with tiered escalation and strike-confirmed handback
|
||||
- Partnership lifecycle: onboard, offboard, transfer
|
||||
- Rsync profile system with per-share container stops and writeback
|
||||
- Watchdog: Tier 1 (explicit) + Tier 2 (global scan) container monitoring
|
||||
- master.conf push-on-save to all configured partners via SSH
|
||||
</CHANGES>
|
||||
|
||||
<!--
|
||||
── 1. Web files symlink (runs on every boot) ──────────────────────────────────
|
||||
Instead of extracting a .txz, we symlink the installed plugin web dir directly
|
||||
to the workspace on flash. Changes to Plugin/unraid/ are live instantly — no
|
||||
package build, no sync step. /boot is always mounted before this runs.
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
<FILE Run="/bin/bash">
|
||||
<INLINE>
|
||||
<![CDATA[
|
||||
#!/bin/bash
|
||||
WEB_DIR="/usr/local/emhttp/plugins/varaverk"
|
||||
SRC="/boot/config/plugins/varaverk/Plugin/unraid"
|
||||
[[ -L "$WEB_DIR" ]] && rm -f "$WEB_DIR"
|
||||
[[ -d "$WEB_DIR" ]] && rm -rf "$WEB_DIR"
|
||||
ln -sf "$SRC" "$WEB_DIR"
|
||||
echo "[Varaverk] web dir symlinked → $SRC"
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
|
||||
<!--
|
||||
── 2. Scripts bootstrap (first install only) ──────────────────────────────────
|
||||
Clones the repo directly into the plugin config dir on flash (/boot/config/plugins/varaverk).
|
||||
No array dependency — scripts live on flash (64GB NVMe) and are available at boot.
|
||||
Uses git init+fetch+reset so the clone works into the non-empty cfgdir (varaverk.cfg,
|
||||
varaverk-*.txz etc. are already there). Never auto-pulls — updates via the UI git pull.
|
||||
|
||||
Clone source priority:
|
||||
1. Gitea (internal) — reads settings from varaverk.cfg; detects container IP at runtime
|
||||
2. GitHub (public) — HTTPS fallback if Gitea is unreachable
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
<FILE Run="/bin/bash" Method="install">
|
||||
<INLINE>
|
||||
<![CDATA[
|
||||
#!/bin/bash
|
||||
PLUGIN="varaverk"
|
||||
CFG_DIR="/boot/config/plugins/$PLUGIN"
|
||||
CFG_FILE="$CFG_DIR/varaverk.cfg"
|
||||
GITHUB="https://github.com/FailedProxy/Varaverk"
|
||||
BRANCH="main"
|
||||
LOG="$CFG_DIR/install.log"
|
||||
|
||||
mkdir -p "$CFG_DIR"
|
||||
log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; }
|
||||
|
||||
# Scripts live in the plugin dir on flash — no array needed.
|
||||
SCRIPTS_DIR="$CFG_DIR"
|
||||
CONF_DIR="$SCRIPTS_DIR/Configurations"
|
||||
|
||||
# ── Boot device check ─────────────────────────────────────────────────────────
|
||||
# Warn if /boot is on a USB/removable device. Varaverk is designed for internal
|
||||
# NVMe/SSD boot — git repo + state files + data writes on USB will wear it out
|
||||
# fast and may run out of space. Install proceeds but user is warned.
|
||||
_boot_dev=$(df /boot --output=source 2>/dev/null | tail -1)
|
||||
_boot_base=$(lsblk -no pkname "$_boot_dev" 2>/dev/null || basename "${_boot_dev%[0-9p]*}")
|
||||
_removable=$(cat "/sys/block/${_boot_base}/removable" 2>/dev/null || echo "0")
|
||||
if [[ "$_removable" == "1" ]]; then
|
||||
log "WARNING: /boot is on a removable/USB device ($_boot_dev)"
|
||||
log "WARNING: Varaverk is designed for internal NVMe/SSD boot."
|
||||
log "WARNING: Running from USB risks drive wear and space exhaustion."
|
||||
log "WARNING: Strongly recommend migrating boot to an internal NVMe/SSD drive."
|
||||
fi
|
||||
unset _boot_dev _boot_base _removable
|
||||
|
||||
# Seed varaverk.cfg with defaults (SCRIPTS_DIR + Gitea settings) if not present.
|
||||
# Requires internal NVMe/SSD boot — scripts live on flash, available before array mounts.
|
||||
if [[ ! -f "$CFG_FILE" ]]; then
|
||||
cat > "$CFG_FILE" <<'CFGEOF'
|
||||
SCRIPTS_DIR="/boot/config/plugins/varaverk"
|
||||
GITEA_CONTAINER="Gitea"
|
||||
GITEA_REPO_PATH="FailedProxy/Varaverk.git"
|
||||
GITEA_SSH_KEY="/root/.ssh/unraid_gitea"
|
||||
SSH_PORT="221"
|
||||
CFGEOF
|
||||
log "seeded varaverk.cfg"
|
||||
fi
|
||||
|
||||
# Read Gitea settings from varaverk.cfg (allows override without editing .plg).
|
||||
_read_cfg() { grep -oP "(?<=^${1}=\")[^\"]*" "$CFG_FILE" 2>/dev/null || echo "${2}"; }
|
||||
GITEA_CONTAINER=$(_read_cfg GITEA_CONTAINER "Gitea")
|
||||
GITEA_REPO_PATH=$(_read_cfg GITEA_REPO_PATH "FailedProxy/Varaverk.git")
|
||||
GITEA_SSH_KEY=$(_read_cfg GITEA_SSH_KEY "/root/.ssh/unraid_gitea")
|
||||
SSH_PORT=$(_read_cfg SSH_PORT "221")
|
||||
|
||||
# Clone on first install only; never auto-pull (updates via the UI git pull).
|
||||
if [[ ! -d "$SCRIPTS_DIR/.git" ]]; then
|
||||
log "initialising repo in $SCRIPTS_DIR ($BRANCH)..."
|
||||
|
||||
# Locate Gitea: local container → local IP; else Tailscale; else fall back to GitHub.
|
||||
GITEA_IP=""
|
||||
if command -v docker >/dev/null 2>&1 && \
|
||||
docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${GITEA_CONTAINER}$"; then
|
||||
GITEA_IP=$(hostname -I | awk '{print $1}')
|
||||
log "Gitea running locally — using $GITEA_IP"
|
||||
elif command -v tailscale >/dev/null 2>&1; then
|
||||
# Try each known peer until we find one hosting Gitea
|
||||
while IFS= read -r peer_ip; do
|
||||
if ssh -i "$GITEA_SSH_KEY" -p "$SSH_PORT" \
|
||||
-o ConnectTimeout=3 -o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes "git@${peer_ip}" info 2>/dev/null | grep -q "varaverk\|Gitea\|gitea"; then
|
||||
GITEA_IP="$peer_ip"
|
||||
log "Gitea found on Tailscale peer $GITEA_IP"
|
||||
break
|
||||
fi
|
||||
done < <(tailscale status --json 2>/dev/null | \
|
||||
python3 -c "import json,sys; d=json.load(sys.stdin); \
|
||||
[print(v['TailscaleIPs'][0]) for v in d.get('Peer',{}).values() \
|
||||
if v.get('TailscaleIPs')]" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# init-in-place — git clone would fail because the dir already has files.
|
||||
git -C "$SCRIPTS_DIR" init >> "$LOG" 2>&1
|
||||
|
||||
CLONED=false
|
||||
if [[ -n "$GITEA_IP" && -f "$GITEA_SSH_KEY" ]]; then
|
||||
GITEA_URL="ssh://git@${GITEA_IP}:${SSH_PORT}/${GITEA_REPO_PATH}"
|
||||
log "trying Gitea: $GITEA_URL"
|
||||
git -C "$SCRIPTS_DIR" remote add origin "$GITEA_URL" >> "$LOG" 2>&1
|
||||
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT -o StrictHostKeyChecking=no" \
|
||||
GIT_TERMINAL_PROMPT=0 \
|
||||
git -C "$SCRIPTS_DIR" fetch --depth=1 origin "$BRANCH" >> "$LOG" 2>&1; then
|
||||
git -C "$SCRIPTS_DIR" reset --hard FETCH_HEAD >> "$LOG" 2>&1
|
||||
git -C "$SCRIPTS_DIR" branch -M "$BRANCH" >> "$LOG" 2>&1
|
||||
git -C "$SCRIPTS_DIR" branch --set-upstream-to=origin/"$BRANCH" "$BRANCH" >> "$LOG" 2>&1
|
||||
log "scripts installed from Gitea ($GITEA_IP)"
|
||||
CLONED=true
|
||||
else
|
||||
log "Gitea fetch failed — falling back to GitHub"
|
||||
git -C "$SCRIPTS_DIR" remote remove origin >> "$LOG" 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$CLONED" == false ]]; then
|
||||
log "trying GitHub: $GITHUB"
|
||||
git -C "$SCRIPTS_DIR" remote add origin "$GITHUB" >> "$LOG" 2>&1
|
||||
if GIT_TERMINAL_PROMPT=0 \
|
||||
git -C "$SCRIPTS_DIR" fetch --depth=1 origin "$BRANCH" >> "$LOG" 2>&1; then
|
||||
git -C "$SCRIPTS_DIR" reset --hard FETCH_HEAD >> "$LOG" 2>&1
|
||||
git -C "$SCRIPTS_DIR" branch -M "$BRANCH" >> "$LOG" 2>&1
|
||||
git -C "$SCRIPTS_DIR" branch --set-upstream-to=origin/"$BRANCH" "$BRANCH" >> "$LOG" 2>&1
|
||||
log "scripts installed from GitHub"
|
||||
CLONED=true
|
||||
else
|
||||
log "WARNING: both Gitea and GitHub failed — scripts not installed, retry when network is up"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log "repo present — leaving scripts untouched (update from the UI)"
|
||||
fi
|
||||
|
||||
# Seed master.conf from template if absent.
|
||||
mkdir -p "$CONF_DIR"
|
||||
if [[ ! -f "$CONF_DIR/master.conf" && -f "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" ]]; then
|
||||
cp "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" "$CONF_DIR/master.conf"
|
||||
log "seeded master.conf from template"
|
||||
fi
|
||||
log "install step complete"
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
|
||||
<!--
|
||||
── 3. Remove ───────────────────────────────────────────────────────────────────
|
||||
Stops background scripts, removes cron, the installed package, and flash config.
|
||||
Scripts/conf in appdata are left intact (delete manually for a full wipe).
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
<FILE Run="/bin/bash" Method="remove">
|
||||
<INLINE>
|
||||
<![CDATA[
|
||||
#!/bin/bash
|
||||
PLUGIN="varaverk"
|
||||
CFG_DIR="/boot/config/plugins/$PLUGIN"
|
||||
CFG_FILE="$CFG_DIR/varaverk.cfg"
|
||||
CRON_FILE="$CFG_DIR/varaverk.cron"
|
||||
WEB_DIR="/usr/local/emhttp/plugins/$PLUGIN"
|
||||
log() { echo "[Varaverk remove] $*"; }
|
||||
|
||||
[[ -f "$CFG_FILE" ]] && _sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
|
||||
SCRIPTS_DIR="${_sd:-$CFG_DIR}"
|
||||
|
||||
# Stop continuous background scripts.
|
||||
if [[ -f "$SCRIPTS_DIR/Fallback/fallback.sh" ]]; then
|
||||
bash "$SCRIPTS_DIR/Fallback/fallback.sh" --stop 2>/dev/null && log "fallback.sh stopped" || true
|
||||
fi
|
||||
pkill -f "run_job.sh" 2>/dev/null || true
|
||||
pkill -f "watchdog_orchestrator.sh" 2>/dev/null || true
|
||||
|
||||
# Remove cron entries.
|
||||
if [[ -f "$CRON_FILE" ]]; then
|
||||
rm -f "$CRON_FILE"
|
||||
/usr/local/sbin/update_cron 2>/dev/null || true
|
||||
log "cron removed"
|
||||
fi
|
||||
rm -f /etc/cron.d/varaverk
|
||||
|
||||
# Remove the installed package (and its RAM files).
|
||||
removepkg "$PLUGIN" 2>/dev/null || true
|
||||
[[ -L "$WEB_DIR" ]] && rm -f "$WEB_DIR"
|
||||
[[ -d "$WEB_DIR" ]] && rm -rf "$WEB_DIR"
|
||||
log "web files removed"
|
||||
|
||||
# Remove flash config (incl. cached .txz).
|
||||
rm -rf "$CFG_DIR"
|
||||
log "flash config removed"
|
||||
log "done — scripts/conf in $SCRIPTS_DIR preserved (delete manually for full wipe)"
|
||||
]]>
|
||||
</INLINE>
|
||||
</FILE>
|
||||
|
||||
</PLUGIN>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$logDir = '/var/log/varaverk';
|
||||
$runs = [];
|
||||
|
||||
try {
|
||||
$ri = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($logDir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($ri as $file) {
|
||||
if ($file->getExtension() !== 'json') continue;
|
||||
$d = @json_decode(@file_get_contents($file->getPathname()), true);
|
||||
if (!is_array($d) || empty($d['start']) || empty($d['status'])) continue;
|
||||
if ($d['status'] === 'running') continue;
|
||||
$id = (string)($d['id'] ?? '');
|
||||
$runs[] = [
|
||||
'id' => $id,
|
||||
'label' => basename(str_replace('.sh', '', $id)),
|
||||
'status' => $d['status'],
|
||||
'start' => (int)$d['start'],
|
||||
'dur' => isset($d['end']) ? max(0, (int)$d['end'] - (int)$d['start']) : 0,
|
||||
];
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
|
||||
usort($runs, fn($a, $b) => $b['start'] - $a['start']);
|
||||
echo json_encode(['ok' => true, 'runs' => array_slice($runs, 0, 24)]);
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$logDir = LOG_DIR;
|
||||
$runs = [];
|
||||
|
||||
try {
|
||||
$ri = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($logDir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($ri as $file) {
|
||||
if ($file->getExtension() !== 'json') continue;
|
||||
$d = @json_decode(@file_get_contents($file->getPathname()), true);
|
||||
if (!is_array($d) || empty($d['start']) || empty($d['status'])) continue;
|
||||
if ($d['status'] === 'running') continue;
|
||||
$id = (string)($d['id'] ?? '');
|
||||
$runs[] = [
|
||||
'id' => $id,
|
||||
'label' => basename(str_replace('.sh', '', $id)),
|
||||
'status' => $d['status'],
|
||||
'start' => (int)$d['start'],
|
||||
'dur' => isset($d['end']) ? max(0, (int)$d['end'] - (int)$d['start']) : 0,
|
||||
];
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
|
||||
usort($runs, fn($a, $b) => $b['start'] - $a['start']);
|
||||
echo json_encode(['ok' => true, 'runs' => array_slice($runs, 0, 24)]);
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Conf Auto-Populate =============================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Reads credentials and settings from locally running services and writes
|
||||
# them into the local host conf. Safe to run multiple times — only populates
|
||||
# EMPTY fields, never overwrites existing values unless --overwrite is passed.
|
||||
#
|
||||
# After populating, pushes the updated conf to all partners via conf_sync.sh
|
||||
# so they have the fresh keys in their /tmp/.vv/ cache immediately.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# AUTO-DETECTED FIELDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# HOSTN_RADARR_API_KEY from Radarr config.xml (found via docker volume mount)
|
||||
# HOSTN_SONARR_API_KEY from Sonarr config.xml
|
||||
# HOSTN_LIDARR_API_KEY from Lidarr config.xml
|
||||
# HOSTN_SLSKD_API_KEY from slskd config.yml
|
||||
# HOSTN_SABNZBD_API_KEY from sabnzbd.ini
|
||||
# HOSTN_EMBY_CONTAINER fuzzy match from docker ps
|
||||
# HOSTN_JELLYFIN_CONTAINER fuzzy match from docker ps
|
||||
# HOSTN_RADARR_MOVIE_ROOT from Radarr rootFolder API
|
||||
# HOSTN_SONARR_TV_ROOT from Sonarr rootFolder API
|
||||
# HOSTN_LIDARR_MUSIC_ROOT from Lidarr rootFolder API
|
||||
# HOSTN_SYS_WATCHDOG_NIC from ip route default gateway interface
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# conf_populate.sh Populate empty fields only
|
||||
# conf_populate.sh --overwrite Overwrite all detected fields (re-sync after arr key rotation)
|
||||
# conf_populate.sh --dry-run Show what would be written, no changes
|
||||
# conf_populate.sh --log Verbose output
|
||||
# conf_populate.sh --no-push Skip pushing to partners after update
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
OVERWRITE=false
|
||||
NO_PUSH=false
|
||||
FILTERED_ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--overwrite) OVERWRITE=true ;;
|
||||
--no-push) NO_PUSH=true ;;
|
||||
*) FILTERED_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
parse_args "${FILTERED_ARGS[@]}"
|
||||
detect_hosts
|
||||
|
||||
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
|
||||
|
||||
CONF_FILE="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
|
||||
[[ ! -f "$CONF_FILE" ]] && { error "Conf file not found: $CONF_FILE"; exit 1; }
|
||||
|
||||
log "$ICON_GEAR Config: conf=${CONF_FILE} overwrite=${OVERWRITE:-false} no-push=${NO_PUSH:-false}"
|
||||
|
||||
UPDATED=0
|
||||
SKIPPED=0
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Conf Auto-Populate — $MY_ID ($LOCAL_SERVER_NAME) ━━━"
|
||||
echo ""
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
[[ "$OVERWRITE" == true ]] && warn "OVERWRITE mode — existing values will be replaced"
|
||||
|
||||
# ── Helper: write a var into conf if empty (or --overwrite) ──────────────────
|
||||
_set_conf_var() {
|
||||
local var_name="$1" value="$2" label="$3"
|
||||
[[ -z "$value" ]] && return
|
||||
|
||||
# Check current value in conf
|
||||
local current
|
||||
current=$(grep -oP "(?<=^\s*${var_name}=\")[^\"]*" "$CONF_FILE" 2>/dev/null | head -1)
|
||||
|
||||
if [[ -n "$current" ]] && [[ "$OVERWRITE" == false ]]; then
|
||||
log "$label: already set (${current:0:8}…) — skipping"
|
||||
(( SKIPPED++ ))
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would set $var_name = ${value:0:8}…"
|
||||
return
|
||||
fi
|
||||
|
||||
# Update or append the var line
|
||||
if grep -q "^\s*${var_name}=" "$CONF_FILE"; then
|
||||
sed -i "s|^\(\s*${var_name}\s*=\s*\)\"[^\"]*\"|\1\"${value}\"|" "$CONF_FILE"
|
||||
else
|
||||
printf '\n %s="%s"\n' "$var_name" "$value" >> "$CONF_FILE"
|
||||
fi
|
||||
info "$label: set ✅"
|
||||
(( UPDATED++ ))
|
||||
}
|
||||
|
||||
# ── Helper: find arr config dir via docker volume mount ───────────────────────
|
||||
# Looks for a container matching the pattern, then reads its /config volume path.
|
||||
# Falls back to DOCKER_APPDATA_BASE/<ContainerName> if volume not found.
|
||||
_arr_config_dir() {
|
||||
local pattern="$1"
|
||||
local container_name
|
||||
container_name=$(docker ps -a --format '{{.Names}}' 2>/dev/null | \
|
||||
grep -im1 "^${pattern}")
|
||||
[[ -z "$container_name" ]] && return 1
|
||||
|
||||
local config_path
|
||||
config_path=$(docker inspect "$container_name" 2>/dev/null | \
|
||||
jq -r '.[0].Mounts[]? | select(.Destination == "/config") | .Source' 2>/dev/null | head -1)
|
||||
[[ -z "$config_path" ]] && config_path="${DOCKER_APPDATA_BASE:-/mnt/user/appdata}/${container_name}"
|
||||
|
||||
[[ -d "$config_path" ]] && echo "$config_path" || return 1
|
||||
}
|
||||
|
||||
# ── Helper: read XML tag value ────────────────────────────────────────────────
|
||||
_xml_val() {
|
||||
local file="$1" tag="$2"
|
||||
grep -oP "(?<=<${tag}>)[^<]+" "$file" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Arr API keys + root paths ─────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
for arr in radarr sonarr lidarr; do
|
||||
arr_upper="${arr^^}"
|
||||
config_dir=$(_arr_config_dir "$arr") || {
|
||||
log "${arr_upper}: no running container found — skipping"
|
||||
continue
|
||||
}
|
||||
config_xml="${config_dir}/config.xml"
|
||||
|
||||
if [[ ! -f "$config_xml" ]]; then
|
||||
log "${arr_upper}: config.xml not found at $config_xml — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
key=$(_xml_val "$config_xml" "ApiKey")
|
||||
port=$(_xml_val "$config_xml" "Port")
|
||||
url_base="http://localhost:${port:-$(case $arr in radarr) echo 7878;; sonarr) echo 8989;; lidarr) echo 8686;; esac)}"
|
||||
|
||||
_set_conf_var "${MY_ID}_${arr_upper}_API_KEY" "$key" "${arr_upper} API key"
|
||||
|
||||
# Root paths from arr's own rootFolder API
|
||||
if [[ -n "$key" ]]; then
|
||||
local api_ver; case "$arr" in lidarr) api_ver="v1" ;; *) api_ver="v3" ;; esac
|
||||
root_json=$(curl -sf --max-time 5 \
|
||||
-H "X-Api-Key: $key" "${url_base}/api/${api_ver}/rootfolder" 2>/dev/null)
|
||||
root_path=$(echo "$root_json" | jq -r '.[0].path // empty' 2>/dev/null)
|
||||
|
||||
case "$arr" in
|
||||
radarr) _set_conf_var "${MY_ID}_RADARR_MOVIE_ROOT" "$root_path" "Radarr movie root" ;;
|
||||
sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT" "$root_path" "Sonarr TV root" ;;
|
||||
lidarr) _set_conf_var "${MY_ID}_LIDARR_MUSIC_ROOT" "$root_path" "Lidarr music root" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
# ==============================================================================================
|
||||
# ── SABnzbd API key ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
sab_dir=$(_arr_config_dir "sabnzbd") && {
|
||||
sab_ini=$(find "$sab_dir" -maxdepth 2 -name "sabnzbd.ini" 2>/dev/null | head -1)
|
||||
if [[ -f "$sab_ini" ]]; then
|
||||
sab_key=$(grep -oP '(?<=^api_key\s*=\s*)\S+' "$sab_ini" 2>/dev/null | head -1)
|
||||
_set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key" "SABnzbd API key"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── slskd API key ─────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
slskd_dir=$(_arr_config_dir "slskd") && {
|
||||
slskd_yml=$(find "$slskd_dir" -maxdepth 2 -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -1)
|
||||
if [[ -f "$slskd_yml" ]]; then
|
||||
slskd_key=$(grep -oP '(?<=api_key:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
|
||||
[[ -z "$slskd_key" ]] && \
|
||||
slskd_key=$(grep -oP '(?<=apikey:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
|
||||
_set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key" "slskd API key"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Container names ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
for pattern in "emby" "jellyfin"; do
|
||||
container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
|
||||
[[ -z "$container" ]] && continue
|
||||
case "$pattern" in
|
||||
emby) _set_conf_var "${MY_ID}_EMBY_CONTAINER" "$container" "Emby container name" ;;
|
||||
jellyfin) _set_conf_var "${MY_ID}_JELLYFIN_CONTAINER" "$container" "Jellyfin container name" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Network interface ─────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
nic=$(ip route show default 2>/dev/null | grep -oP '(?<=dev )\S+' | head -1)
|
||||
_set_conf_var "${MY_ID}_SYS_WATCHDOG_NIC" "$nic" "Default NIC"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Summary + push ────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY Conf Populate Summary ━━━━━"
|
||||
echo " Updated: $UPDATED field(s)"
|
||||
echo " Skipped: $SKIPPED already set"
|
||||
echo " Conf: $CONF_FILE"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
if [[ "$UPDATED" -gt 0 ]] && [[ "$DRY_RUN" == false ]] && [[ "$NO_PUSH" == false ]]; then
|
||||
echo ""
|
||||
info "Pushing updated conf to partners..."
|
||||
bash "$SCRIPTS_ROOT/System_Essentials/conf_sync.sh" --push-only "${EXTRA_FLAGS[@]}" || true
|
||||
fi
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Conf Auto-Populate =============================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Reads credentials and settings from locally running services and writes
|
||||
# them into the local host conf. Safe to run multiple times — only populates
|
||||
# EMPTY fields, never overwrites existing values unless --overwrite is passed.
|
||||
#
|
||||
# After populating, pushes the updated conf to all partners via conf_sync.sh
|
||||
# so they have the fresh keys in their /tmp/.vv/ cache immediately.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# AUTO-DETECTED FIELDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# HOSTN_RADARR_API_KEY from Radarr config.xml (found via docker volume mount)
|
||||
# HOSTN_SONARR_API_KEY from Sonarr config.xml
|
||||
# HOSTN_LIDARR_API_KEY from Lidarr config.xml
|
||||
# HOSTN_SLSKD_API_KEY from slskd config.yml
|
||||
# HOSTN_SABNZBD_API_KEY from sabnzbd.ini
|
||||
# HOSTN_EMBY_CONTAINER fuzzy match from docker ps
|
||||
# HOSTN_JELLYFIN_CONTAINER fuzzy match from docker ps
|
||||
# HOSTN_RADARR_MOVIE_ROOT from Radarr rootFolder API
|
||||
# HOSTN_SONARR_TV_ROOT from Sonarr rootFolder API
|
||||
# HOSTN_LIDARR_MUSIC_ROOT from Lidarr rootFolder API
|
||||
# HOSTN_SYS_WATCHDOG_NIC from ip route default gateway interface
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# conf_populate.sh Populate empty fields only
|
||||
# conf_populate.sh --overwrite Overwrite all detected fields (re-sync after arr key rotation)
|
||||
# conf_populate.sh --dry-run Show what would be written, no changes
|
||||
# conf_populate.sh --log Verbose output
|
||||
# conf_populate.sh --no-push Skip pushing to partners after update
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../../../load_config.sh"
|
||||
SCRIPTS_ROOT="$SCRIPTS_DIR"
|
||||
|
||||
OVERWRITE=false
|
||||
NO_PUSH=false
|
||||
FILTERED_ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--overwrite) OVERWRITE=true ;;
|
||||
--no-push) NO_PUSH=true ;;
|
||||
*) FILTERED_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
parse_args "${FILTERED_ARGS[@]}"
|
||||
detect_hosts
|
||||
|
||||
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
|
||||
|
||||
CONF_FILE="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
|
||||
[[ ! -f "$CONF_FILE" ]] && { error "Conf file not found: $CONF_FILE"; exit 1; }
|
||||
|
||||
log "$ICON_GEAR Config: conf=${CONF_FILE} overwrite=${OVERWRITE:-false} no-push=${NO_PUSH:-false}"
|
||||
|
||||
UPDATED=0
|
||||
SKIPPED=0
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Conf Auto-Populate — $MY_ID ($LOCAL_SERVER_NAME) ━━━"
|
||||
echo ""
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
[[ "$OVERWRITE" == true ]] && warn "OVERWRITE mode — existing values will be replaced"
|
||||
|
||||
# ── Helper: write a var into conf if empty (or --overwrite) ──────────────────
|
||||
_set_conf_var() {
|
||||
local var_name="$1" value="$2" label="$3"
|
||||
[[ -z "$value" ]] && return
|
||||
|
||||
# Check current value in conf
|
||||
local current
|
||||
current=$(grep -oP "(?<=^\s*${var_name}=\")[^\"]*" "$CONF_FILE" 2>/dev/null | head -1)
|
||||
|
||||
if [[ -n "$current" ]] && [[ "$OVERWRITE" == false ]]; then
|
||||
log "$label: already set (${current:0:8}…) — skipping"
|
||||
(( SKIPPED++ ))
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would set $var_name = ${value:0:8}…"
|
||||
return
|
||||
fi
|
||||
|
||||
# Update or append the var line
|
||||
if grep -q "^\s*${var_name}=" "$CONF_FILE"; then
|
||||
sed -i "s|^\(\s*${var_name}\s*=\s*\)\"[^\"]*\"|\1\"${value}\"|" "$CONF_FILE"
|
||||
else
|
||||
printf '\n %s="%s"\n' "$var_name" "$value" >> "$CONF_FILE"
|
||||
fi
|
||||
info "$label: set ✅"
|
||||
(( UPDATED++ ))
|
||||
}
|
||||
|
||||
# ── Helper: find arr config dir via docker volume mount ───────────────────────
|
||||
# Looks for a container matching the pattern, then reads its /config volume path.
|
||||
# Falls back to DOCKER_APPDATA_BASE/<ContainerName> if volume not found.
|
||||
_arr_config_dir() {
|
||||
local pattern="$1"
|
||||
local container_name
|
||||
container_name=$(docker ps -a --format '{{.Names}}' 2>/dev/null | \
|
||||
grep -im1 "^${pattern}")
|
||||
[[ -z "$container_name" ]] && return 1
|
||||
|
||||
local config_path
|
||||
config_path=$(docker inspect "$container_name" 2>/dev/null | \
|
||||
jq -r '.[0].Mounts[]? | select(.Destination == "/config") | .Source' 2>/dev/null | head -1)
|
||||
[[ -z "$config_path" ]] && config_path="${DOCKER_APPDATA_BASE:-/mnt/user/appdata}/${container_name}"
|
||||
|
||||
[[ -d "$config_path" ]] && echo "$config_path" || return 1
|
||||
}
|
||||
|
||||
# ── Helper: read XML tag value ────────────────────────────────────────────────
|
||||
_xml_val() {
|
||||
local file="$1" tag="$2"
|
||||
grep -oP "(?<=<${tag}>)[^<]+" "$file" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Arr API keys + root paths ─────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
for arr in radarr sonarr lidarr; do
|
||||
arr_upper="${arr^^}"
|
||||
config_dir=$(_arr_config_dir "$arr") || {
|
||||
log "${arr_upper}: no running container found — skipping"
|
||||
continue
|
||||
}
|
||||
config_xml="${config_dir}/config.xml"
|
||||
|
||||
if [[ ! -f "$config_xml" ]]; then
|
||||
log "${arr_upper}: config.xml not found at $config_xml — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
key=$(_xml_val "$config_xml" "ApiKey")
|
||||
port=$(_xml_val "$config_xml" "Port")
|
||||
url_base="http://localhost:${port:-$(case $arr in radarr) echo 7878;; sonarr) echo 8989;; lidarr) echo 8686;; esac)}"
|
||||
|
||||
_set_conf_var "${MY_ID}_${arr_upper}_API_KEY" "$key" "${arr_upper} API key"
|
||||
|
||||
# Root paths from arr's own rootFolder API
|
||||
if [[ -n "$key" ]]; then
|
||||
local api_ver; case "$arr" in lidarr) api_ver="v1" ;; *) api_ver="v3" ;; esac
|
||||
root_json=$(curl -sf --max-time 5 \
|
||||
-H "X-Api-Key: $key" "${url_base}/api/${api_ver}/rootfolder" 2>/dev/null)
|
||||
root_path=$(echo "$root_json" | jq -r '.[0].path // empty' 2>/dev/null)
|
||||
|
||||
case "$arr" in
|
||||
radarr) _set_conf_var "${MY_ID}_RADARR_MOVIE_ROOT" "$root_path" "Radarr movie root" ;;
|
||||
sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT" "$root_path" "Sonarr TV root" ;;
|
||||
lidarr) _set_conf_var "${MY_ID}_LIDARR_MUSIC_ROOT" "$root_path" "Lidarr music root" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
# ==============================================================================================
|
||||
# ── SABnzbd API key ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
sab_dir=$(_arr_config_dir "sabnzbd") && {
|
||||
sab_ini=$(find "$sab_dir" -maxdepth 2 -name "sabnzbd.ini" 2>/dev/null | head -1)
|
||||
if [[ -f "$sab_ini" ]]; then
|
||||
sab_key=$(grep -oP '(?<=^api_key\s*=\s*)\S+' "$sab_ini" 2>/dev/null | head -1)
|
||||
_set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key" "SABnzbd API key"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── slskd API key ─────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
slskd_dir=$(_arr_config_dir "slskd") && {
|
||||
slskd_yml=$(find "$slskd_dir" -maxdepth 2 -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -1)
|
||||
if [[ -f "$slskd_yml" ]]; then
|
||||
slskd_key=$(grep -oP '(?<=api_key:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
|
||||
[[ -z "$slskd_key" ]] && \
|
||||
slskd_key=$(grep -oP '(?<=apikey:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
|
||||
_set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key" "slskd API key"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Container names ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
for pattern in "emby" "jellyfin"; do
|
||||
container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
|
||||
[[ -z "$container" ]] && continue
|
||||
case "$pattern" in
|
||||
emby) _set_conf_var "${MY_ID}_EMBY_CONTAINER" "$container" "Emby container name" ;;
|
||||
jellyfin) _set_conf_var "${MY_ID}_JELLYFIN_CONTAINER" "$container" "Jellyfin container name" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Network interface ─────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
nic=$(ip route show default 2>/dev/null | grep -oP '(?<=dev )\S+' | head -1)
|
||||
_set_conf_var "${MY_ID}_SYS_WATCHDOG_NIC" "$nic" "Default NIC"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Summary + push ────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY Conf Populate Summary ━━━━━"
|
||||
echo " Updated: $UPDATED field(s)"
|
||||
echo " Skipped: $SKIPPED already set"
|
||||
echo " Conf: $CONF_FILE"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
if [[ "$UPDATED" -gt 0 ]] && [[ "$DRY_RUN" == false ]] && [[ "$NO_PUSH" == false ]]; then
|
||||
echo ""
|
||||
info "Pushing updated conf to partners..."
|
||||
bash "$SCRIPTS_ROOT/System_Essentials/conf_sync.sh" --push-only "${EXTRA_FLAGS[@]}" || true
|
||||
fi
|
||||
+879
@@ -0,0 +1,879 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Partnership Onboard ============================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Runs once on both servers to establish a new partnership. Role is detected
|
||||
# automatically via detect_hosts() — no flags needed to declare which side you are.
|
||||
# Run on the mirror first (generates its SSH key), then on the owner to complete
|
||||
# setup remotely.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL MODEL
|
||||
# ==============================================================================================
|
||||
#
|
||||
# MIRROR PATH (1 step)
|
||||
# Step 1: SSH key setup — generate keypair, copy to owner, update conf
|
||||
# Owner completes the rest remotely. Mirror is done.
|
||||
#
|
||||
# OWNER PATH (8 steps)
|
||||
# Step 1: SSH key setup — generate keypair, install on mirror, update conf
|
||||
# Step 2: Plugin install — FolderView3 and required plugins on mirror
|
||||
# Step 3: Stop mirror auth — stop mirror's existing auth containers before replacing
|
||||
# Step 4: Deploy auth stack — push XMLs, pull images, create + start on mirror
|
||||
# Mariadb/Redis health-checked before Authelia deploys
|
||||
# Step 5: Stop mirror arr — stop mirror's existing arr containers before replacing
|
||||
# Step 6: Deploy arr stack — push arr XMLs, pull images, create + start on mirror
|
||||
# Step 7: Partnership onboard — configure WebUIs → owner IP, write state, FolderView3, Emby
|
||||
# Step 8: Arr bootstrap — bidirectional library sync (arr_sync.sh)
|
||||
# Step 9: Conf push — push master.conf + setup state to all listed hosts
|
||||
#
|
||||
# ==============================================================================================
|
||||
# DESIGN PRINCIPLES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Credentials never in SSH command strings
|
||||
# Auth stack containers hold API keys, DB passwords, etc. The deploy script is written
|
||||
# locally, SCPed to the remote, and executed there. Command-line args are never used
|
||||
# to pass credentials — they'd appear in `ps` output and shell history on both servers.
|
||||
#
|
||||
# XML templates are the single source of truth for deployed containers
|
||||
# The owner's templates-user/ XMLs define every container deployed on the mirror.
|
||||
# The same XMLs that Unraid's Docker Manager uses are what get SCPed — the mirror's
|
||||
# Docker Manager can manage the containers after onboard without additional config.
|
||||
#
|
||||
# Dependency ordering in the auth stack is owner-enforced
|
||||
# PARTNERSHIP_AUTH_STACK order matters: Mariadb and Redis must come before Authelia.
|
||||
# The array is ordered correctly in host1.conf. After each Mariadb/Redis deploy,
|
||||
# the script waits for the container to be healthy before continuing. This is a remote
|
||||
# health check — the container must be running (or report healthy) before the next
|
||||
# dependent is deployed.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Root check
|
||||
# All operations run as root — SSH key management, docker operations, conf updates.
|
||||
#
|
||||
# SSH timeout on all remote calls
|
||||
# Every ssh/scp call uses SSH_TIMEOUT. No operation hangs indefinitely on a
|
||||
# slow or unreachable mirror.
|
||||
#
|
||||
# --dry-run shows exact actions without executing
|
||||
# Every step prints what it would do. SCP, deploy, plugin install, arr sync —
|
||||
# all dry-run safe.
|
||||
#
|
||||
# Step skip flags for partial re-runs
|
||||
# --skip-ssh, --skip-auth-stack, --skip-arr-stack, --skip-arr-sync allow
|
||||
# resuming after a partial failure without re-running completed steps.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION
|
||||
# ==============================================================================================
|
||||
#
|
||||
# host*.conf
|
||||
#
|
||||
# HOST*_PARTNERSHIP_AUTH_STACK
|
||||
# XML filenames (from this server's templates-user/) to push and deploy on the
|
||||
# mirror as its auth stack. Order matters: database deps before Authelia.
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_AUTH_STACK
|
||||
#
|
||||
# HOST*_PARTNERSHIP_REPLACE_CONTAINERS
|
||||
# Containers to stop on the mirror before deploying the auth stack.
|
||||
# Defined in the MIRROR's own conf (host*.conf on HOST2) — never in HOST1's conf.
|
||||
# Read live from the mirror via SSH during Step 3 (sources mirror's load_config.sh at
|
||||
# the same $SCRIPTS_ROOT path — convention: both servers use the same repo location).
|
||||
# Leave empty on HOST2 if no conflicting containers exist (fresh mirror: nothing to stop).
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_REPLACE_CONTAINERS (on the mirror)
|
||||
#
|
||||
# HOST*_PARTNERSHIP_ARR_STACK
|
||||
# XML filenames to push and deploy on the mirror as its arr stack.
|
||||
# Leave empty to skip arr stack deploy.
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_ARR_STACK
|
||||
#
|
||||
# HOST*_PARTNERSHIP_ARR_REPLACE_CONTAINERS
|
||||
# Arr containers to stop on the mirror before deploying the arr stack.
|
||||
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_ARR_REPLACE_CONTAINERS (on the mirror)
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Partnership/partnership_onboard.sh
|
||||
# Full onboard — role detected automatically
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --dry-run
|
||||
# Preview all steps without making changes
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --log
|
||||
# Verbose per-step output
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-ssh
|
||||
# Skip SSH key setup (key already in place)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-auth-stack
|
||||
# Skip auth stack stop + deploy (Steps 3-4)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-arr-stack
|
||||
# Skip arr stack stop + deploy (Steps 5-6)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-arr-sync
|
||||
# Skip arr library bootstrap (Step 8)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --phase1-only
|
||||
# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.
|
||||
# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --phase2-only
|
||||
# OWNER only: container deploy + arr + onboard (skips SSH). Triggered automatically
|
||||
# by HOST2 after it completes its Mirror-path onboard. Can also be run manually.
|
||||
# Writes HOST2_PHASE2_DONE=true to varaverk_setup.db.
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPTS_ROOT="$SCRIPT_DIR/.."
|
||||
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
|
||||
SSH_TIMEOUT=15
|
||||
|
||||
source "$SCRIPTS_ROOT/load_config.sh"
|
||||
|
||||
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
|
||||
SKIP_SSH=false
|
||||
SKIP_AUTH_STACK=false
|
||||
SKIP_ARR_STACK=false
|
||||
SKIP_ARR_SYNC=false
|
||||
PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)
|
||||
PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)
|
||||
FILTERED_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-ssh) SKIP_SSH=true ;;
|
||||
--skip-auth-stack) SKIP_AUTH_STACK=true ;;
|
||||
--skip-arr-stack) SKIP_ARR_STACK=true ;;
|
||||
--skip-arr-sync) SKIP_ARR_SYNC=true ;;
|
||||
--phase1-only) PHASE1_ONLY=true ;;
|
||||
--phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;
|
||||
*) FILTERED_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
parse_args "${FILTERED_ARGS[@]}"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
|
||||
|
||||
acquire_lock
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
error "Docker command not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_hosts
|
||||
|
||||
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
|
||||
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
|
||||
OWNER="${!OWNER_ID}"
|
||||
MIRROR="${!MIRROR_ID}"
|
||||
# SSH_KEY (set by detect_hosts) is this server's own private key.
|
||||
# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.
|
||||
# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's
|
||||
# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.
|
||||
MIRROR_SSH_KEY="$SSH_KEY"
|
||||
|
||||
AM_OWNER=false
|
||||
AM_MIRROR=false
|
||||
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
|
||||
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
|
||||
|
||||
EXTRA_FLAGS=()
|
||||
[[ "$DRY_RUN" == true ]] && EXTRA_FLAGS+=("--dry-run")
|
||||
[[ "$LOG_MODE" == true ]] && EXTRA_FLAGS+=("--log")
|
||||
|
||||
START=$(date +%s)
|
||||
|
||||
# ── Helper: write phase completion flag to setup.db + push to remotes ─────────────────────────
|
||||
write_onboard_phase() {
|
||||
local target_id="$1" phase="$2"
|
||||
local key="${target_id}_PHASE${phase}_DONE"
|
||||
local state_file="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
|
||||
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
|
||||
if grep -q "^${key}=" "$state_file" 2>/dev/null; then
|
||||
sed -i "s|^${key}=.*|${key}=true|" "$state_file"
|
||||
else
|
||||
echo "${key}=true" >> "$state_file"
|
||||
fi
|
||||
command -v php &>/dev/null && \
|
||||
php -r "require_once '/usr/local/emhttp/plugins/varaverk/include/config.php'; vv_push_setup_state();" 2>/dev/null || true
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
echo ""
|
||||
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
|
||||
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
|
||||
echo ""
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── HELPER: deploy a container from a local Unraid template XML to a remote host ─────────────
|
||||
#
|
||||
# Parses Port / Path / Variable Config entries from the XML, SCPs the template and a
|
||||
# self-contained deploy script to the remote, executes it, then cleans up both sides.
|
||||
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
|
||||
# ==============================================================================================
|
||||
deploy_container_from_xml() {
|
||||
local xml_file="$1" remote_ip="$2" ssh_key="$3"
|
||||
local xml_name
|
||||
xml_name=$(basename "$xml_file")
|
||||
|
||||
# Extract top-level fields
|
||||
local name repo network extra privileged
|
||||
name=$( awk 'match($0,/<Name>([^<]+)<\/Name>/, a){print a[1];exit}' "$xml_file")
|
||||
repo=$( awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
|
||||
network=$( awk 'match($0,/<Network>([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file")
|
||||
extra=$( awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
|
||||
privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")
|
||||
|
||||
if [[ -z "$name" || -z "$repo" ]]; then
|
||||
warn " Cannot parse Name/Repository from $xml_name — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Deploying $name..."
|
||||
|
||||
# SCP the XML so Unraid Docker Manager recognises and can manage the container
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
|
||||
"$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
|
||||
warn " SCP failed for $xml_name — skipping $name"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
warn " DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
|
||||
fi
|
||||
|
||||
# Build a self-contained deploy script locally.
|
||||
# Writing to a temp file keeps credentials out of SSH command strings.
|
||||
local tmp_script
|
||||
tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
|
||||
chmod 600 "$tmp_script"
|
||||
|
||||
{
|
||||
echo "#!/bin/bash"
|
||||
echo "set -e"
|
||||
echo ""
|
||||
printf "docker pull %q 2>/dev/null || true\n" "$repo"
|
||||
printf "docker stop %q 2>/dev/null || true\n" "$name"
|
||||
printf "docker rm %q 2>/dev/null || true\n" "$name"
|
||||
echo ""
|
||||
printf "docker create --name %q --restart=unless-stopped" "$name"
|
||||
[[ -n "$network" ]] && printf " --network=%q" "$network"
|
||||
[[ "$privileged" == "true" ]] && printf " --privileged"
|
||||
[[ -n "$extra" ]] && printf " %s" "$extra"
|
||||
|
||||
# Port mappings → -p host:container/proto
|
||||
awk '/Type="Port"/ {
|
||||
match($0, /Target="([^"]+)"/, t)
|
||||
match($0, /Mode="([^"]+)"/, m)
|
||||
match($0, />([^<]+)<\/Config>/, v)
|
||||
if (t[1] != "" && v[1] != "") {
|
||||
proto = (m[1] == "udp") ? "udp" : "tcp"
|
||||
printf " -p %s:%s/%s", v[1], t[1], proto
|
||||
}
|
||||
}' "$xml_file"
|
||||
|
||||
# Volume mappings → -v 'host:container:mode'
|
||||
awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
|
||||
match($0, /Target="([^"]+)"/, t)
|
||||
match($0, /Mode="([^"]+)"/, m)
|
||||
match($0, />([^<]+)<\/Config>/, v)
|
||||
if (t[1] != "" && v[1] != "") {
|
||||
mode = (m[1] == "ro") ? "ro" : "rw"
|
||||
printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
|
||||
}
|
||||
}' "$xml_file"
|
||||
|
||||
# Environment variables → -e 'KEY=VALUE' (single-quoted to protect $ and special chars)
|
||||
awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
|
||||
match($0, /Target="([^"]+)"/, t)
|
||||
match($0, />([^<]+)<\/Config>/, v)
|
||||
if (t[1] != "" && v[1] != "") {
|
||||
printf " -e %s%s=%s%s", q, t[1], v[1], q
|
||||
}
|
||||
}' "$xml_file"
|
||||
|
||||
printf " %q\n" "$repo"
|
||||
echo ""
|
||||
printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
|
||||
} > "$tmp_script"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would deploy $name on $MIRROR"
|
||||
rm -f "$tmp_script"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# SCP deploy script → remote, execute, clean up both sides
|
||||
local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"
|
||||
|
||||
if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
|
||||
"$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
|
||||
timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
||||
"bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
|
||||
grep -q "deployed:${name}"; then
|
||||
log " $name deployed ✅"
|
||||
rm -f "$tmp_script"
|
||||
return 0
|
||||
else
|
||||
warn " $name deployment failed — check $MIRROR manually"
|
||||
rm -f "$tmp_script"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── HELPER: wait for a container on the remote to be healthy/running ─────────────────────────
|
||||
#
|
||||
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
|
||||
# falls back to the running state for containers with no healthcheck. Non-fatal after timeout
|
||||
# — Authelia may take time to fully initialize but the deploy itself succeeded.
|
||||
# ==============================================================================================
|
||||
wait_for_container_healthy() {
|
||||
local name="$1" remote_ip="$2" ssh_key="$3"
|
||||
local max_wait=60 interval=5 elapsed=0
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && return 0
|
||||
|
||||
log " Waiting for $name to be ready..."
|
||||
while (( elapsed < max_wait )); do
|
||||
local status
|
||||
status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
|
||||
"h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
|
||||
r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
|
||||
echo \${h:-\$r}" 2>/dev/null)
|
||||
|
||||
case "$status" in
|
||||
healthy|true)
|
||||
log " $name ready ✅"
|
||||
return 0
|
||||
;;
|
||||
starting|unhealthy|false|"")
|
||||
sleep "$interval"
|
||||
(( elapsed += interval ))
|
||||
;;
|
||||
*)
|
||||
sleep "$interval"
|
||||
(( elapsed += interval ))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
warn " $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
|
||||
#
|
||||
# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers
|
||||
# use the same convention), and reads the named config array from the mirror's own conf.
|
||||
# HOST2's container list stays in HOST2's host2.conf — not duplicated in HOST1's conf.
|
||||
# Fails gracefully if scripts aren't present yet or the array is empty (nothing to stop).
|
||||
#
|
||||
# deploy_container_from_xml() already stops/removes containers with the same name as what's
|
||||
# being deployed. This step handles containers with DIFFERENT names that conflict.
|
||||
# ==============================================================================================
|
||||
stop_mirror_stack() {
|
||||
local config_var="$1" label="$2"
|
||||
local -a to_stop=()
|
||||
|
||||
mapfile -t to_stop < <(
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
|
||||
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
|
||||
detect_hosts 2>/dev/null
|
||||
printf '%s\n' \"\${${config_var}[@]:-}\"" 2>/dev/null | grep -v '^$'
|
||||
)
|
||||
|
||||
if [[ ${#to_stop[@]} -eq 0 ]]; then
|
||||
log "No $label containers to stop on $MIRROR — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Stopping $label on $MIRROR: ${to_stop[*]}"
|
||||
for container in "${to_stop[@]}"; do
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would stop + rm $container on $MIRROR"
|
||||
continue
|
||||
fi
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
|
||||
"docker stop '$container' 2>/dev/null
|
||||
docker rm '$container' 2>/dev/null && echo removed" 2>/dev/null | \
|
||||
grep -q removed && \
|
||||
log " $container removed ✅" || \
|
||||
log " $container not found on $MIRROR — skipping"
|
||||
done
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── HELPER: deploy a stack of XMLs to the mirror, health-checking db deps between batches ────
|
||||
#
|
||||
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
|
||||
# This avoids the process-substitution capture problem: warn() writes to stdout, so any
|
||||
# read -r X Y < <(func) would capture warn output as the count values.
|
||||
# ==============================================================================================
|
||||
_STACK_DEPLOYED=0
|
||||
_STACK_FAILED=0
|
||||
|
||||
deploy_xml_stack() {
|
||||
local -n xml_array_ref="$1"
|
||||
_STACK_DEPLOYED=0
|
||||
_STACK_FAILED=0
|
||||
|
||||
for xml_name in "${xml_array_ref[@]}"; do
|
||||
local xml_file="${TEMPLATES_DIR}/${xml_name}"
|
||||
if [[ ! -f "$xml_file" ]]; then
|
||||
warn "$xml_name not found in $TEMPLATES_DIR — skipping"
|
||||
(( _STACK_FAILED++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract container name to use for health-wait matching
|
||||
local cname
|
||||
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
|
||||
|
||||
if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
|
||||
(( _STACK_DEPLOYED++ ))
|
||||
# Health-check database deps before continuing — they must be ready before
|
||||
# Authelia/app containers that depend on them can start cleanly.
|
||||
if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
|
||||
wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
|
||||
fi
|
||||
else
|
||||
(( _STACK_FAILED++ ))
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
if [[ "$AM_MIRROR" == true ]]; then
|
||||
echo "━━━ Step 1/2 — SSH Key Setup (Mirror) ━━━"
|
||||
echo ""
|
||||
echo " Mirror sets up SSH keys, then notifies Owner to run Phase 2."
|
||||
echo ""
|
||||
|
||||
if [[ "$SKIP_SSH" == true ]]; then
|
||||
warn "Skipping SSH setup (--skip-ssh)"
|
||||
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
log "SSH key ready ✅"
|
||||
else
|
||||
error "SSH key setup failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━"
|
||||
echo ""
|
||||
|
||||
OWNER_IP=$(resolve_tailscale_ip "$OWNER" 2>/dev/null || true)
|
||||
PHASE2_TRIGGERED=false
|
||||
|
||||
if [[ -n "$OWNER_IP" ]]; then
|
||||
# Read OWNER's SCRIPTS_DIR from their varaverk.cfg — don't assume same path as mirror
|
||||
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
|
||||
'grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null | cut -d= -f2 | tr -d "\"'"'"'" 2>/dev/null' 2>/dev/null | tr -d '[:space:]')
|
||||
OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-/boot/config/plugins/varaverk}"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
|
||||
PHASE2_TRIGGERED=true
|
||||
elif timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
|
||||
"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered" \
|
||||
2>/dev/null | grep -q triggered; then
|
||||
log "Phase 2 triggered on $OWNER ✅"
|
||||
log "Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log"
|
||||
PHASE2_TRIGGERED=true
|
||||
else
|
||||
warn "Could not auto-trigger Phase 2 on $OWNER"
|
||||
fi
|
||||
else
|
||||
warn "Cannot resolve $OWNER Tailscale IP"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY MIRROR SETUP COMPLETE ━━━━━"
|
||||
echo " SSH key: ready"
|
||||
echo " Phase 2 on $OWNER: $( [[ "$PHASE2_TRIGGERED" == true ]] && echo "triggered ✅" || echo "needs manual trigger ⚠" )"
|
||||
if [[ "$PHASE2_TRIGGERED" == false ]]; then
|
||||
echo ""
|
||||
echo " Run manually on $OWNER:"
|
||||
echo " bash Partnership/partnership_onboard.sh --phase2-only"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
|
||||
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
|
||||
log "Mirror: $MIRROR ($MIRROR_IP)"
|
||||
[[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)"
|
||||
[[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)"
|
||||
echo ""
|
||||
|
||||
STEP_SSH_OK=false
|
||||
STEP_PLUGINS_OK=true
|
||||
STEP_STOP_AUTH_OK=true
|
||||
STEP_AUTH_OK=true
|
||||
AUTH_DEPLOYED=0
|
||||
AUTH_FAILED=0
|
||||
STEP_STOP_ARR_OK=true
|
||||
STEP_ARR_OK=true
|
||||
ARR_DEPLOYED=0
|
||||
ARR_FAILED=0
|
||||
ONBOARD_OK=false
|
||||
ARR_SYNC_OK=false
|
||||
MASTER_PUSH_OK=false
|
||||
|
||||
# ── Step 1: SSH ───────────────────────────────────────────────────────────────────────────────
|
||||
# Skipped when --phase2-only (SSH was already done in Phase 1).
|
||||
echo "━━━ Step 1 — SSH Key Setup ━━━"
|
||||
|
||||
if [[ "$SKIP_SSH" == true ]]; then
|
||||
warn "Skipping (--skip-ssh)"
|
||||
STEP_SSH_OK=true
|
||||
elif [[ "$PHASE1_ONLY" == true ]]; then
|
||||
# Phase 1 in background: test if SSH already works first — avoids ssh-copy-id
|
||||
# hanging for a password prompt with no TTY.
|
||||
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" exit 0 2>/dev/null; then
|
||||
log "SSH to $MIRROR already works ✅ — skipping key install"
|
||||
STEP_SSH_OK=true
|
||||
else
|
||||
# Key not yet on HOST2 — try ssh_setup.sh (works interactively, may fail in background)
|
||||
if bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
log "SSH keys ready ✅"
|
||||
STEP_SSH_OK=true
|
||||
else
|
||||
# Soft-fail: generate key locally if not present, then tell user to install manually
|
||||
warn "Could not install key on $MIRROR automatically (no terminal for password prompt)"
|
||||
if [[ -f "$SSH_KEY" ]]; then
|
||||
log "Local key exists at: $SSH_KEY"
|
||||
else
|
||||
bash "$SCRIPT_DIR/ssh_setup.sh" --key-only "${EXTRA_FLAGS[@]}" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -f "${SSH_KEY}.pub" ]]; then
|
||||
echo ""
|
||||
echo " Install this key on $MIRROR to complete SSH setup:"
|
||||
echo " ┌─────────────────────────────────────────────────────"
|
||||
cat "${SSH_KEY}.pub" | sed 's/^/ │ /'
|
||||
echo " └─────────────────────────────────────────────────────"
|
||||
echo " Run on a terminal: ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
|
||||
echo " Then click 'Push Conf' in the Partnership tab."
|
||||
# Write key-ready flag so UI can show the manual-install state
|
||||
[[ "$DRY_RUN" == false ]] && {
|
||||
local kflag="${MIRROR_ID}_KEY_READY"
|
||||
local _setup_f="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
|
||||
grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
|
||||
&& sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|
||||
|| echo "${kflag}=true" >> "$_setup_f"
|
||||
}
|
||||
fi
|
||||
STEP_SSH_OK=false
|
||||
fi
|
||||
fi
|
||||
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
log "SSH keys ready ✅"
|
||||
STEP_SSH_OK=true
|
||||
else
|
||||
error "SSH key setup failed — aborting"
|
||||
error "Re-run or use --skip-ssh if key is already set up"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Phase 1 exit point ────────────────────────────────────────────────────────────────────────
|
||||
# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.
|
||||
# HOST2's wizard will detect the pushed master.conf + state file and take the correct path.
|
||||
if [[ "$PHASE1_ONLY" == true ]]; then
|
||||
if [[ "$STEP_SSH_OK" == false ]]; then
|
||||
# SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.
|
||||
# UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
|
||||
echo ""
|
||||
echo "━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━"
|
||||
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
|
||||
warn "Local setup had issues — FolderView3 may need manual setup"
|
||||
|
||||
END=$(date +%s)
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━"
|
||||
echo " SSH keys: key generated ✅ — NOT yet installed on $MIRROR ⚠"
|
||||
echo " Conf push: skipped (needs SSH access to $MIRROR)"
|
||||
echo " HOST1 setup: done ✅"
|
||||
echo " Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
echo " ACTION NEEDED: install the key on $MIRROR:"
|
||||
echo " ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
|
||||
echo " Then click 'Push Conf' in Partnership tab, or run:"
|
||||
echo " bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━ Phase 1 — Conf Push ━━━"
|
||||
|
||||
CONF_PUSH_OK=false
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would push master.conf + state file to $MIRROR"
|
||||
CONF_PUSH_OK=true
|
||||
elif ! command -v php &>/dev/null; then
|
||||
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
|
||||
else
|
||||
push_output=$(php -r "
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
|
||||
\$results = vv_push_master_conf();
|
||||
vv_push_setup_state();
|
||||
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
|
||||
\$failed = 0;
|
||||
foreach (\$results as \$r) {
|
||||
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
|
||||
if (!\$r['ok']) \$failed++;
|
||||
}
|
||||
exit(\$failed > 0 ? 1 : 0);
|
||||
" 2>/dev/null)
|
||||
push_rc=$?
|
||||
echo "$push_output"
|
||||
if [[ $push_rc -eq 0 ]]; then
|
||||
log "Conf push complete ✅"
|
||||
CONF_PUSH_OK=true
|
||||
else
|
||||
warn "Conf push had failures — retry via Scheduler → master.conf → Save Conf"
|
||||
fi
|
||||
fi
|
||||
|
||||
# HOST1 local setup — runs immediately without needing HOST2
|
||||
echo ""
|
||||
echo "━━━ Phase 1 — HOST1 Local Setup ━━━"
|
||||
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
|
||||
warn "Local setup had issues — FolderView3 may need manual setup"
|
||||
|
||||
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
|
||||
|
||||
END=$(date +%s)
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━"
|
||||
echo " SSH keys: $( [[ "$STEP_SSH_OK" == true ]] && echo "ready ✅" || echo "skipped" )"
|
||||
echo " Conf push: $( [[ "$CONF_PUSH_OK" == true ]] && echo "done ✅" || echo "⚠ manual needed" )"
|
||||
echo " HOST1 setup: done ✅"
|
||||
echo " Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
|
||||
echo " The wizard will detect the pushed conf and take the correct path."
|
||||
echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 2 — Plugin Install on Mirror ━━━"
|
||||
|
||||
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && [[ -n "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
|
||||
FV3_PRESENT=$(timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
|
||||
"test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)
|
||||
|
||||
if [[ "$FV3_PRESENT" == "yes" ]]; then
|
||||
log "FolderView3 already installed on $MIRROR ✅"
|
||||
elif [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would install FolderView3 on $MIRROR"
|
||||
else
|
||||
log "Installing FolderView3 on $MIRROR..."
|
||||
timeout 60 ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
|
||||
"plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \
|
||||
2>/dev/null | grep -q installed && \
|
||||
log "FolderView3 installed ✅" || {
|
||||
warn "FolderView3 install failed — install manually from Community Applications"
|
||||
STEP_PLUGINS_OK=false
|
||||
}
|
||||
fi
|
||||
else
|
||||
log "FolderView3 not configured — skipping"
|
||||
fi
|
||||
|
||||
# ── Step 3: Stop mirror's existing auth stack ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 3 — Stop Mirror Auth Stack ━━━"
|
||||
|
||||
if [[ "$SKIP_AUTH_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-auth-stack)"
|
||||
else
|
||||
stop_mirror_stack "PARTNERSHIP_REPLACE_CONTAINERS" "auth stack"
|
||||
fi
|
||||
|
||||
# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 4 — Deploy Auth Stack on Mirror ━━━"
|
||||
|
||||
if [[ "$SKIP_AUTH_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-auth-stack)"
|
||||
elif [[ ${#PARTNERSHIP_AUTH_STACK[@]} -eq 0 ]]; then
|
||||
warn "PARTNERSHIP_AUTH_STACK not set in ${MY_ID} conf — skipping auth stack deploy"
|
||||
warn "Add HOST${MY_ID: -1}_PARTNERSHIP_AUTH_STACK to host${MY_ID: -1}.conf"
|
||||
STEP_AUTH_OK=false
|
||||
else
|
||||
deploy_xml_stack PARTNERSHIP_AUTH_STACK
|
||||
AUTH_DEPLOYED=$_STACK_DEPLOYED
|
||||
AUTH_FAILED=$_STACK_FAILED
|
||||
echo "Auth stack: $AUTH_DEPLOYED deployed, $AUTH_FAILED failed"
|
||||
[[ "$AUTH_FAILED" -gt 0 ]] && STEP_AUTH_OK=false
|
||||
fi
|
||||
|
||||
# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 5 — Stop Mirror Arr Stack ━━━"
|
||||
|
||||
if [[ "$SKIP_ARR_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-arr-stack)"
|
||||
elif [[ ${#PARTNERSHIP_ARR_STACK[@]} -eq 0 ]]; then
|
||||
log "PARTNERSHIP_ARR_STACK not configured — skipping arr stack deploy"
|
||||
SKIP_ARR_STACK=true
|
||||
else
|
||||
stop_mirror_stack "PARTNERSHIP_ARR_REPLACE_CONTAINERS" "arr stack"
|
||||
fi
|
||||
|
||||
# ── Step 6: Deploy arr stack on mirror ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 6 — Deploy Arr Stack on Mirror ━━━"
|
||||
|
||||
if [[ "$SKIP_ARR_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-arr-stack)"
|
||||
else
|
||||
deploy_xml_stack PARTNERSHIP_ARR_STACK
|
||||
ARR_DEPLOYED=$_STACK_DEPLOYED
|
||||
ARR_FAILED=$_STACK_FAILED
|
||||
echo "Arr stack: $ARR_DEPLOYED deployed, $ARR_FAILED failed"
|
||||
[[ "$ARR_FAILED" -gt 0 ]] && STEP_ARR_OK=false
|
||||
fi
|
||||
|
||||
# ── Step 7: Partnership onboard ───────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 7 — Partnership Onboard ━━━"
|
||||
|
||||
if bash "$SCRIPTS_ROOT/Partnership/partnership_manager.sh" --onboard "${EXTRA_FLAGS[@]}"; then
|
||||
echo "Partnership onboard complete ✅"
|
||||
ONBOARD_OK=true
|
||||
else
|
||||
error "Partnership onboard failed"
|
||||
ONBOARD_OK=false
|
||||
fi
|
||||
|
||||
# ── Step 8: Arr library bootstrap ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 8 — Arr Library Bootstrap ━━━"
|
||||
|
||||
if [[ "$ONBOARD_OK" == false ]]; then
|
||||
warn "Skipping — onboard did not complete"
|
||||
elif [[ "$SKIP_ARR_SYNC" == true ]]; then
|
||||
warn "Skipping (--skip-arr-sync)"
|
||||
elif [[ ! -f "$SCRIPTS_ROOT/Media/arr_sync.sh" ]]; then
|
||||
warn "arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live"
|
||||
elif bash "$SCRIPTS_ROOT/Media/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
echo "Arr bootstrap complete ✅"
|
||||
ARR_SYNC_OK=true
|
||||
else
|
||||
warn "Arr sync had errors — partnership still valid"
|
||||
warn "Re-run Media/arr_sync.sh once all arr containers are live"
|
||||
fi
|
||||
|
||||
# ── Step 9: Push master.conf to all listed hosts ──────────────────────────────────────────────
|
||||
# SSH is now established and all partners have the plugin installed.
|
||||
# Push the authoritative master.conf so every listed host is in sync immediately.
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 9 — master.conf Push ━━━"
|
||||
|
||||
if [[ "$ONBOARD_OK" == false ]]; then
|
||||
warn "Skipping — onboard did not complete"
|
||||
elif [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would push master.conf to all listed hosts"
|
||||
MASTER_PUSH_OK=true
|
||||
elif ! command -v php &>/dev/null; then
|
||||
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
|
||||
else
|
||||
push_output=$(php -r "
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
|
||||
\$results = vv_push_master_conf();
|
||||
vv_push_setup_state();
|
||||
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
|
||||
\$failed = 0;
|
||||
foreach (\$results as \$r) {
|
||||
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
|
||||
if (!\$r['ok']) \$failed++;
|
||||
}
|
||||
exit(\$failed > 0 ? 1 : 0);
|
||||
" 2>/dev/null)
|
||||
push_rc=$?
|
||||
echo "$push_output"
|
||||
if [[ $push_rc -eq 0 ]]; then
|
||||
echo "master.conf sync complete ✅"
|
||||
MASTER_PUSH_OK=true
|
||||
else
|
||||
warn "master.conf push had failures — retry via Scheduler → master.conf → Save Conf"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────
|
||||
[[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
|
||||
END=$(date +%s)
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━"
|
||||
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo " Mirror: $MIRROR ($MIRROR_IP)"
|
||||
[[ "$PHASE2_ONLY" == true ]] && echo " Mode: Phase 2 (triggered by HOST2 notification)"
|
||||
echo " Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
|
||||
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
|
||||
|
||||
echo " Step 1 — SSH keys: $(_skip "$SKIP_SSH" "$STEP_SSH_OK")"
|
||||
echo " Step 2 — Plugins: $(_ok "$STEP_PLUGINS_OK")"
|
||||
echo " Step 3 — Stop auth: $(_skip "$SKIP_AUTH_STACK" "$STEP_STOP_AUTH_OK")"
|
||||
echo " Step 4 — Auth stack: $( [[ "$SKIP_AUTH_STACK" == true ]] && echo "skipped" || echo "${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed" )"
|
||||
echo " Step 5 — Stop arr: $(_skip "$SKIP_ARR_STACK" "$STEP_STOP_ARR_OK")"
|
||||
echo " Step 6 — Arr stack: $( [[ "$SKIP_ARR_STACK" == true ]] && echo "skipped" || echo "${ARR_DEPLOYED} deployed, ${ARR_FAILED} failed" )"
|
||||
echo " Step 7 — Onboard: $(_ok "$ONBOARD_OK")"
|
||||
echo " Step 8 — Arr bootstrap: $( [[ "$SKIP_ARR_SYNC" == true || "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$ARR_SYNC_OK")" )"
|
||||
echo " Step 9 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )"
|
||||
echo ""
|
||||
|
||||
if [[ "$ONBOARD_OK" == true ]]; then
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
|
||||
echo "$ICON_DONE DONE — partnership established ✅"
|
||||
echo "Verify with: Partnership/partnership_manager.sh --status"
|
||||
else
|
||||
error "Setup incomplete — resolve errors above and re-run"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
[[ "$ONBOARD_OK" == false ]] && exit 1
|
||||
exit 0
|
||||
+659
@@ -0,0 +1,659 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Partnership Onboard ============================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Runs once on both servers to establish a new partnership. Role is detected
|
||||
# automatically via detect_hosts() — no flags needed to declare which side you are.
|
||||
# Run on the mirror first (generates its SSH key), then on the owner to complete
|
||||
# setup remotely.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL MODEL
|
||||
# ==============================================================================================
|
||||
#
|
||||
# MIRROR PATH (1 step)
|
||||
# Step 1: SSH key setup — generate keypair, copy to owner, update conf
|
||||
# Owner completes the rest remotely. Mirror is done.
|
||||
#
|
||||
# OWNER PATH (8 steps)
|
||||
# Step 1: SSH key setup — generate keypair, install on mirror, update conf
|
||||
# Step 2: Plugin install — FolderView3 and required plugins on mirror
|
||||
# Step 3: Stop mirror auth — stop mirror's existing auth containers before replacing
|
||||
# Step 4: Deploy auth stack — push XMLs, pull images, create + start on mirror
|
||||
# Mariadb/Redis health-checked before Authelia deploys
|
||||
# Step 5: Stop mirror arr — stop mirror's existing arr containers before replacing
|
||||
# Step 6: Deploy arr stack — push arr XMLs, pull images, create + start on mirror
|
||||
# Step 7: Partnership onboard — configure WebUIs → owner IP, write state, FolderView3, Emby
|
||||
# Step 8: Arr bootstrap — bidirectional library sync (arr_sync.sh)
|
||||
# Step 9: Conf push — push master.conf + setup state to all listed hosts
|
||||
#
|
||||
# ==============================================================================================
|
||||
# DESIGN PRINCIPLES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Credentials never in SSH command strings
|
||||
# Auth stack containers hold API keys, DB passwords, etc. The deploy script is written
|
||||
# locally, SCPed to the remote, and executed there. Command-line args are never used
|
||||
# to pass credentials — they'd appear in `ps` output and shell history on both servers.
|
||||
#
|
||||
# XML templates are the single source of truth for deployed containers
|
||||
# The owner's templates-user/ XMLs define every container deployed on the mirror.
|
||||
# The same XMLs that Unraid's Docker Manager uses are what get SCPed — the mirror's
|
||||
# Docker Manager can manage the containers after onboard without additional config.
|
||||
#
|
||||
# Dependency ordering in the auth stack is owner-enforced
|
||||
# PARTNERSHIP_AUTH_STACK order matters: Mariadb and Redis must come before Authelia.
|
||||
# The array is ordered correctly in host1.conf. After each Mariadb/Redis deploy,
|
||||
# the script waits for the container to be healthy before continuing. This is a remote
|
||||
# health check — the container must be running (or report healthy) before the next
|
||||
# dependent is deployed.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Root check
|
||||
# All operations run as root — SSH key management, docker operations, conf updates.
|
||||
#
|
||||
# SSH timeout on all remote calls
|
||||
# Every ssh/scp call uses SSH_TIMEOUT. No operation hangs indefinitely on a
|
||||
# slow or unreachable mirror.
|
||||
#
|
||||
# --dry-run shows exact actions without executing
|
||||
# Every step prints what it would do. SCP, deploy, plugin install, arr sync —
|
||||
# all dry-run safe.
|
||||
#
|
||||
# Step skip flags for partial re-runs
|
||||
# --skip-ssh, --skip-auth-stack, --skip-arr-stack, --skip-arr-sync allow
|
||||
# resuming after a partial failure without re-running completed steps.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION
|
||||
# ==============================================================================================
|
||||
#
|
||||
# host*.conf
|
||||
#
|
||||
# HOST*_PARTNERSHIP_AUTH_STACK
|
||||
# XML filenames (from this server's templates-user/) to push and deploy on the
|
||||
# mirror as its auth stack. Order matters: database deps before Authelia.
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_AUTH_STACK
|
||||
#
|
||||
# HOST*_PARTNERSHIP_REPLACE_CONTAINERS
|
||||
# Containers to stop on the mirror before deploying the auth stack.
|
||||
# Defined in the MIRROR's own conf (host*.conf on HOST2) — never in HOST1's conf.
|
||||
# Read live from the mirror via SSH during Step 3 (sources mirror's load_config.sh at
|
||||
# the same $SCRIPTS_ROOT path — convention: both servers use the same repo location).
|
||||
# Leave empty on HOST2 if no conflicting containers exist (fresh mirror: nothing to stop).
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_REPLACE_CONTAINERS (on the mirror)
|
||||
#
|
||||
# HOST*_PARTNERSHIP_ARR_STACK
|
||||
# XML filenames to push and deploy on the mirror as its arr stack.
|
||||
# Leave empty to skip arr stack deploy.
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_ARR_STACK
|
||||
#
|
||||
# HOST*_PARTNERSHIP_ARR_REPLACE_CONTAINERS
|
||||
# Arr containers to stop on the mirror before deploying the arr stack.
|
||||
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_ARR_REPLACE_CONTAINERS (on the mirror)
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Partnership/partnership_onboard.sh
|
||||
# Full onboard — role detected automatically
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --dry-run
|
||||
# Preview all steps without making changes
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --log
|
||||
# Verbose per-step output
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-ssh
|
||||
# Skip SSH key setup (key already in place)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-auth-stack
|
||||
# Skip auth stack stop + deploy (Steps 3-4)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-arr-stack
|
||||
# Skip arr stack stop + deploy (Steps 5-6)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-arr-sync
|
||||
# Skip arr library bootstrap (Step 8)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --phase1-only
|
||||
# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.
|
||||
# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --phase2-only
|
||||
# OWNER only: container deploy + arr + onboard (skips SSH). Triggered automatically
|
||||
# by HOST2 after it completes its Mirror-path onboard. Can also be run manually.
|
||||
# Writes HOST2_PHASE2_DONE=true to varaverk_setup.db.
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPTS_ROOT="$SCRIPT_DIR/.."
|
||||
SSH_TIMEOUT=15
|
||||
|
||||
source "$SCRIPTS_ROOT/load_config.sh"
|
||||
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
|
||||
|
||||
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
|
||||
SKIP_SSH=false
|
||||
SKIP_AUTH_STACK=false
|
||||
SKIP_ARR_STACK=false
|
||||
SKIP_ARR_SYNC=false
|
||||
PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)
|
||||
PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)
|
||||
FILTERED_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-ssh) SKIP_SSH=true ;;
|
||||
--skip-auth-stack) SKIP_AUTH_STACK=true ;;
|
||||
--skip-arr-stack) SKIP_ARR_STACK=true ;;
|
||||
--skip-arr-sync) SKIP_ARR_SYNC=true ;;
|
||||
--phase1-only) PHASE1_ONLY=true ;;
|
||||
--phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;
|
||||
*) FILTERED_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
parse_args "${FILTERED_ARGS[@]}"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
|
||||
|
||||
acquire_lock
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
error "Docker command not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_hosts
|
||||
|
||||
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
|
||||
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
|
||||
OWNER="${!OWNER_ID}"
|
||||
MIRROR="${!MIRROR_ID}"
|
||||
# SSH_KEY (set by detect_hosts) is this server's own private key.
|
||||
# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.
|
||||
# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's
|
||||
# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.
|
||||
MIRROR_SSH_KEY="$SSH_KEY"
|
||||
|
||||
AM_OWNER=false
|
||||
AM_MIRROR=false
|
||||
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
|
||||
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
|
||||
|
||||
EXTRA_FLAGS=()
|
||||
[[ "$DRY_RUN" == true ]] && EXTRA_FLAGS+=("--dry-run")
|
||||
[[ "$LOG_MODE" == true ]] && EXTRA_FLAGS+=("--log")
|
||||
|
||||
START=$(date +%s)
|
||||
|
||||
# ── Helper: write phase completion flag to setup.db + push to remotes ─────────────────────────
|
||||
write_onboard_phase() {
|
||||
local target_id="$1" phase="$2"
|
||||
local key="${target_id}_PHASE${phase}_DONE"
|
||||
local state_file="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
|
||||
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
|
||||
if grep -q "^${key}=" "$state_file" 2>/dev/null; then
|
||||
sed -i "s|^${key}=.*|${key}=true|" "$state_file"
|
||||
else
|
||||
echo "${key}=true" >> "$state_file"
|
||||
fi
|
||||
command -v php &>/dev/null && \
|
||||
php -r "require_once '/usr/local/emhttp/plugins/varaverk/include/config.php'; vv_push_setup_state();" 2>/dev/null || true
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
echo ""
|
||||
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
|
||||
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
|
||||
echo ""
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
|
||||
#
|
||||
# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers
|
||||
# use the same convention), and reads the named config array from the mirror's own conf.
|
||||
# HOST2's container list stays in HOST2's host2.conf — not duplicated in HOST1's conf.
|
||||
# Fails gracefully if scripts aren't present yet or the array is empty (nothing to stop).
|
||||
#
|
||||
# deploy_container_from_xml() already stops/removes containers with the same name as what's
|
||||
# being deployed. This step handles containers with DIFFERENT names that conflict.
|
||||
# ==============================================================================================
|
||||
stop_mirror_stack() {
|
||||
local config_var="$1" label="$2"
|
||||
local -a to_stop=()
|
||||
|
||||
mapfile -t to_stop < <(
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
|
||||
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
|
||||
detect_hosts 2>/dev/null
|
||||
printf '%s\n' \"\${${config_var}[@]:-}\"" 2>/dev/null | grep -v '^$'
|
||||
)
|
||||
|
||||
if [[ ${#to_stop[@]} -eq 0 ]]; then
|
||||
log "No $label containers to stop on $MIRROR — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Stopping $label on $MIRROR: ${to_stop[*]}"
|
||||
for container in "${to_stop[@]}"; do
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would stop + rm $container on $MIRROR"
|
||||
continue
|
||||
fi
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
|
||||
"docker stop '$container' 2>/dev/null
|
||||
docker rm '$container' 2>/dev/null && echo removed" 2>/dev/null | \
|
||||
grep -q removed && \
|
||||
log " $container removed ✅" || \
|
||||
log " $container not found on $MIRROR — skipping"
|
||||
done
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
if [[ "$AM_MIRROR" == true ]]; then
|
||||
echo "━━━ Step 1/2 — SSH Key Setup (Mirror) ━━━"
|
||||
echo ""
|
||||
echo " Mirror sets up SSH keys, then notifies Owner to run Phase 2."
|
||||
echo ""
|
||||
|
||||
if [[ "$SKIP_SSH" == true ]]; then
|
||||
warn "Skipping SSH setup (--skip-ssh)"
|
||||
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
log "SSH key ready ✅"
|
||||
else
|
||||
error "SSH key setup failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━"
|
||||
echo ""
|
||||
|
||||
OWNER_IP=$(resolve_tailscale_ip "$OWNER" 2>/dev/null || true)
|
||||
PHASE2_TRIGGERED=false
|
||||
|
||||
if [[ -n "$OWNER_IP" ]]; then
|
||||
# Read OWNER's SCRIPTS_DIR from their varaverk.cfg — don't assume same path as mirror
|
||||
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
|
||||
'grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null | cut -d= -f2 | tr -d "\"'"'"'" 2>/dev/null' 2>/dev/null | tr -d '[:space:]')
|
||||
OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-/boot/config/plugins/varaverk}"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
|
||||
PHASE2_TRIGGERED=true
|
||||
elif timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
|
||||
"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered" \
|
||||
2>/dev/null | grep -q triggered; then
|
||||
log "Phase 2 triggered on $OWNER ✅"
|
||||
log "Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log"
|
||||
PHASE2_TRIGGERED=true
|
||||
else
|
||||
warn "Could not auto-trigger Phase 2 on $OWNER"
|
||||
fi
|
||||
else
|
||||
warn "Cannot resolve $OWNER Tailscale IP"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY MIRROR SETUP COMPLETE ━━━━━"
|
||||
echo " SSH key: ready"
|
||||
echo " Phase 2 on $OWNER: $( [[ "$PHASE2_TRIGGERED" == true ]] && echo "triggered ✅" || echo "needs manual trigger ⚠" )"
|
||||
if [[ "$PHASE2_TRIGGERED" == false ]]; then
|
||||
echo ""
|
||||
echo " Run manually on $OWNER:"
|
||||
echo " bash Partnership/partnership_onboard.sh --phase2-only"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
|
||||
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
|
||||
log "Mirror: $MIRROR ($MIRROR_IP)"
|
||||
[[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)"
|
||||
[[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)"
|
||||
echo ""
|
||||
|
||||
STEP_SSH_OK=false
|
||||
STEP_PLUGINS_OK=true
|
||||
STEP_STOP_AUTH_OK=true
|
||||
STEP_AUTH_OK=true
|
||||
AUTH_DEPLOYED=0
|
||||
AUTH_FAILED=0
|
||||
STEP_STOP_ARR_OK=true
|
||||
STEP_ARR_OK=true
|
||||
ARR_DEPLOYED=0
|
||||
ARR_FAILED=0
|
||||
ONBOARD_OK=false
|
||||
ARR_SYNC_OK=false
|
||||
MASTER_PUSH_OK=false
|
||||
|
||||
# ── Step 1: SSH ───────────────────────────────────────────────────────────────────────────────
|
||||
# Skipped when --phase2-only (SSH was already done in Phase 1).
|
||||
echo "━━━ Step 1 — SSH Key Setup ━━━"
|
||||
|
||||
if [[ "$SKIP_SSH" == true ]]; then
|
||||
warn "Skipping (--skip-ssh)"
|
||||
STEP_SSH_OK=true
|
||||
elif [[ "$PHASE1_ONLY" == true ]]; then
|
||||
# Phase 1 in background: test if SSH already works first — avoids ssh-copy-id
|
||||
# hanging for a password prompt with no TTY.
|
||||
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" exit 0 2>/dev/null; then
|
||||
log "SSH to $MIRROR already works ✅ — skipping key install"
|
||||
STEP_SSH_OK=true
|
||||
else
|
||||
# Key not yet on HOST2 — try ssh_setup.sh (works interactively, may fail in background)
|
||||
if bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
log "SSH keys ready ✅"
|
||||
STEP_SSH_OK=true
|
||||
else
|
||||
# Soft-fail: generate key locally if not present, then tell user to install manually
|
||||
warn "Could not install key on $MIRROR automatically (no terminal for password prompt)"
|
||||
if [[ -f "$SSH_KEY" ]]; then
|
||||
log "Local key exists at: $SSH_KEY"
|
||||
else
|
||||
bash "$SCRIPT_DIR/ssh_setup.sh" --key-only "${EXTRA_FLAGS[@]}" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -f "${SSH_KEY}.pub" ]]; then
|
||||
echo ""
|
||||
echo " Install this key on $MIRROR to complete SSH setup:"
|
||||
echo " ┌─────────────────────────────────────────────────────"
|
||||
cat "${SSH_KEY}.pub" | sed 's/^/ │ /'
|
||||
echo " └─────────────────────────────────────────────────────"
|
||||
echo " Run on a terminal: ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
|
||||
echo " Then click 'Push Conf' in the Partnership tab."
|
||||
# Write key-ready flag so UI can show the manual-install state
|
||||
[[ "$DRY_RUN" == false ]] && {
|
||||
local kflag="${MIRROR_ID}_KEY_READY"
|
||||
local _setup_f="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
|
||||
grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
|
||||
&& sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|
||||
|| echo "${kflag}=true" >> "$_setup_f"
|
||||
}
|
||||
fi
|
||||
STEP_SSH_OK=false
|
||||
fi
|
||||
fi
|
||||
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
log "SSH keys ready ✅"
|
||||
STEP_SSH_OK=true
|
||||
else
|
||||
error "SSH key setup failed — aborting"
|
||||
error "Re-run or use --skip-ssh if key is already set up"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Phase 1 exit point ────────────────────────────────────────────────────────────────────────
|
||||
# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.
|
||||
# HOST2's wizard will detect the pushed master.conf + state file and take the correct path.
|
||||
if [[ "$PHASE1_ONLY" == true ]]; then
|
||||
if [[ "$STEP_SSH_OK" == false ]]; then
|
||||
# SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.
|
||||
# UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
|
||||
echo ""
|
||||
echo "━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━"
|
||||
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
|
||||
warn "Local setup had issues — FolderView3 may need manual setup"
|
||||
|
||||
END=$(date +%s)
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━"
|
||||
echo " SSH keys: key generated ✅ — NOT yet installed on $MIRROR ⚠"
|
||||
echo " Conf push: skipped (needs SSH access to $MIRROR)"
|
||||
echo " HOST1 setup: done ✅"
|
||||
echo " Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
echo " ACTION NEEDED: install the key on $MIRROR:"
|
||||
echo " ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
|
||||
echo " Then click 'Push Conf' in Partnership tab, or run:"
|
||||
echo " bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━ Phase 1 — Conf Push ━━━"
|
||||
|
||||
CONF_PUSH_OK=false
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would push master.conf + state file to $MIRROR"
|
||||
CONF_PUSH_OK=true
|
||||
elif ! command -v php &>/dev/null; then
|
||||
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
|
||||
else
|
||||
push_output=$(php -r "
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
|
||||
\$results = vv_push_master_conf();
|
||||
vv_push_setup_state();
|
||||
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
|
||||
\$failed = 0;
|
||||
foreach (\$results as \$r) {
|
||||
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
|
||||
if (!\$r['ok']) \$failed++;
|
||||
}
|
||||
exit(\$failed > 0 ? 1 : 0);
|
||||
" 2>/dev/null)
|
||||
push_rc=$?
|
||||
echo "$push_output"
|
||||
if [[ $push_rc -eq 0 ]]; then
|
||||
log "Conf push complete ✅"
|
||||
CONF_PUSH_OK=true
|
||||
else
|
||||
warn "Conf push had failures — retry via Scheduler → master.conf → Save Conf"
|
||||
fi
|
||||
fi
|
||||
|
||||
# HOST1 local setup — runs immediately without needing HOST2
|
||||
echo ""
|
||||
echo "━━━ Phase 1 — HOST1 Local Setup ━━━"
|
||||
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
|
||||
warn "Local setup had issues — FolderView3 may need manual setup"
|
||||
|
||||
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
|
||||
|
||||
END=$(date +%s)
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━"
|
||||
echo " SSH keys: $( [[ "$STEP_SSH_OK" == true ]] && echo "ready ✅" || echo "skipped" )"
|
||||
echo " Conf push: $( [[ "$CONF_PUSH_OK" == true ]] && echo "done ✅" || echo "⚠ manual needed" )"
|
||||
echo " HOST1 setup: done ✅"
|
||||
echo " Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
|
||||
echo " The wizard will detect the pushed conf and take the correct path."
|
||||
echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 2 — Plugin Install on Mirror ━━━"
|
||||
|
||||
platform_install_plugins "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_PLUGINS_OK=false
|
||||
|
||||
# ── Step 3: Stop mirror's existing auth stack ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 3 — Stop Mirror Auth Stack ━━━"
|
||||
|
||||
if [[ "$SKIP_AUTH_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-auth-stack)"
|
||||
else
|
||||
stop_mirror_stack "PARTNERSHIP_REPLACE_CONTAINERS" "auth stack"
|
||||
fi
|
||||
|
||||
# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 4 — Deploy Auth Stack on Mirror ━━━"
|
||||
|
||||
if [[ "$SKIP_AUTH_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-auth-stack)"
|
||||
elif [[ ${#PARTNERSHIP_AUTH_STACK[@]} -eq 0 ]]; then
|
||||
warn "PARTNERSHIP_AUTH_STACK not set in ${MY_ID} conf — skipping auth stack deploy"
|
||||
warn "Add HOST${MY_ID: -1}_PARTNERSHIP_AUTH_STACK to host${MY_ID: -1}.conf"
|
||||
STEP_AUTH_OK=false
|
||||
else
|
||||
deploy_xml_stack PARTNERSHIP_AUTH_STACK
|
||||
AUTH_DEPLOYED=$_STACK_DEPLOYED
|
||||
AUTH_FAILED=$_STACK_FAILED
|
||||
echo "Auth stack: $AUTH_DEPLOYED deployed, $AUTH_FAILED failed"
|
||||
[[ "$AUTH_FAILED" -gt 0 ]] && STEP_AUTH_OK=false
|
||||
fi
|
||||
|
||||
# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 5 — Stop Mirror Arr Stack ━━━"
|
||||
|
||||
if [[ "$SKIP_ARR_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-arr-stack)"
|
||||
elif [[ ${#PARTNERSHIP_ARR_STACK[@]} -eq 0 ]]; then
|
||||
log "PARTNERSHIP_ARR_STACK not configured — skipping arr stack deploy"
|
||||
SKIP_ARR_STACK=true
|
||||
else
|
||||
stop_mirror_stack "PARTNERSHIP_ARR_REPLACE_CONTAINERS" "arr stack"
|
||||
fi
|
||||
|
||||
# ── Step 6: Deploy arr stack on mirror ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 6 — Deploy Arr Stack on Mirror ━━━"
|
||||
|
||||
if [[ "$SKIP_ARR_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-arr-stack)"
|
||||
else
|
||||
deploy_xml_stack PARTNERSHIP_ARR_STACK
|
||||
ARR_DEPLOYED=$_STACK_DEPLOYED
|
||||
ARR_FAILED=$_STACK_FAILED
|
||||
echo "Arr stack: $ARR_DEPLOYED deployed, $ARR_FAILED failed"
|
||||
[[ "$ARR_FAILED" -gt 0 ]] && STEP_ARR_OK=false
|
||||
fi
|
||||
|
||||
# ── Step 7: Partnership onboard ───────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 7 — Partnership Onboard ━━━"
|
||||
|
||||
if bash "$SCRIPTS_ROOT/Partnership/partnership_manager.sh" --onboard "${EXTRA_FLAGS[@]}"; then
|
||||
echo "Partnership onboard complete ✅"
|
||||
ONBOARD_OK=true
|
||||
else
|
||||
error "Partnership onboard failed"
|
||||
ONBOARD_OK=false
|
||||
fi
|
||||
|
||||
# ── Step 8: Arr library bootstrap ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 8 — Arr Library Bootstrap ━━━"
|
||||
|
||||
if [[ "$ONBOARD_OK" == false ]]; then
|
||||
warn "Skipping — onboard did not complete"
|
||||
elif [[ "$SKIP_ARR_SYNC" == true ]]; then
|
||||
warn "Skipping (--skip-arr-sync)"
|
||||
elif [[ ! -f "$SCRIPTS_ROOT/Media/arr_sync.sh" ]]; then
|
||||
warn "arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live"
|
||||
elif bash "$SCRIPTS_ROOT/Media/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
echo "Arr bootstrap complete ✅"
|
||||
ARR_SYNC_OK=true
|
||||
else
|
||||
warn "Arr sync had errors — partnership still valid"
|
||||
warn "Re-run Media/arr_sync.sh once all arr containers are live"
|
||||
fi
|
||||
|
||||
# ── Step 9: Push master.conf to all listed hosts ──────────────────────────────────────────────
|
||||
# SSH is now established and all partners have the plugin installed.
|
||||
# Push the authoritative master.conf so every listed host is in sync immediately.
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 9 — master.conf Push ━━━"
|
||||
|
||||
if [[ "$ONBOARD_OK" == false ]]; then
|
||||
warn "Skipping — onboard did not complete"
|
||||
elif [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would push master.conf to all listed hosts"
|
||||
MASTER_PUSH_OK=true
|
||||
elif ! command -v php &>/dev/null; then
|
||||
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
|
||||
else
|
||||
push_output=$(php -r "
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
|
||||
\$results = vv_push_master_conf();
|
||||
vv_push_setup_state();
|
||||
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
|
||||
\$failed = 0;
|
||||
foreach (\$results as \$r) {
|
||||
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
|
||||
if (!\$r['ok']) \$failed++;
|
||||
}
|
||||
exit(\$failed > 0 ? 1 : 0);
|
||||
" 2>/dev/null)
|
||||
push_rc=$?
|
||||
echo "$push_output"
|
||||
if [[ $push_rc -eq 0 ]]; then
|
||||
echo "master.conf sync complete ✅"
|
||||
MASTER_PUSH_OK=true
|
||||
else
|
||||
warn "master.conf push had failures — retry via Scheduler → master.conf → Save Conf"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────
|
||||
[[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
|
||||
END=$(date +%s)
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━"
|
||||
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo " Mirror: $MIRROR ($MIRROR_IP)"
|
||||
[[ "$PHASE2_ONLY" == true ]] && echo " Mode: Phase 2 (triggered by HOST2 notification)"
|
||||
echo " Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
|
||||
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
|
||||
|
||||
echo " Step 1 — SSH keys: $(_skip "$SKIP_SSH" "$STEP_SSH_OK")"
|
||||
echo " Step 2 — Plugins: $(_ok "$STEP_PLUGINS_OK")"
|
||||
echo " Step 3 — Stop auth: $(_skip "$SKIP_AUTH_STACK" "$STEP_STOP_AUTH_OK")"
|
||||
echo " Step 4 — Auth stack: $( [[ "$SKIP_AUTH_STACK" == true ]] && echo "skipped" || echo "${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed" )"
|
||||
echo " Step 5 — Stop arr: $(_skip "$SKIP_ARR_STACK" "$STEP_STOP_ARR_OK")"
|
||||
echo " Step 6 — Arr stack: $( [[ "$SKIP_ARR_STACK" == true ]] && echo "skipped" || echo "${ARR_DEPLOYED} deployed, ${ARR_FAILED} failed" )"
|
||||
echo " Step 7 — Onboard: $(_ok "$ONBOARD_OK")"
|
||||
echo " Step 8 — Arr bootstrap: $( [[ "$SKIP_ARR_SYNC" == true || "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$ARR_SYNC_OK")" )"
|
||||
echo " Step 9 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )"
|
||||
echo ""
|
||||
|
||||
if [[ "$ONBOARD_OK" == true ]]; then
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
|
||||
echo "$ICON_DONE DONE — partnership established ✅"
|
||||
echo "Verify with: Partnership/partnership_manager.sh --status"
|
||||
else
|
||||
error "Setup incomplete — resolve errors above and re-run"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
[[ "$ONBOARD_OK" == false ]] && exit 1
|
||||
exit 0
|
||||
+650
@@ -0,0 +1,650 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Partnership Onboard ============================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Runs once on both servers to establish a new partnership. Role is detected
|
||||
# automatically via detect_hosts() — no flags needed to declare which side you are.
|
||||
# Run on the mirror first (generates its SSH key), then on the owner to complete
|
||||
# setup remotely.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL MODEL
|
||||
# ==============================================================================================
|
||||
#
|
||||
# MIRROR PATH (1 step)
|
||||
# Step 1: SSH key setup — generate keypair, copy to owner, update conf
|
||||
# Owner completes the rest remotely. Mirror is done.
|
||||
#
|
||||
# OWNER PATH (8 steps)
|
||||
# Step 1: SSH key setup — generate keypair, install on mirror, update conf
|
||||
# Step 2: Stop mirror auth — stop mirror's existing auth containers before replacing
|
||||
# Step 3: Deploy auth stack — push XMLs, pull images, create + start on mirror
|
||||
# Mariadb/Redis health-checked before Authelia deploys
|
||||
# Step 4: Stop mirror arr — stop mirror's existing arr containers before replacing
|
||||
# Step 5: Deploy arr stack — push arr XMLs, pull images, create + start on mirror
|
||||
# Step 6: Partnership onboard — configure WebUIs → owner IP, write state, Emby
|
||||
# Step 7: Arr bootstrap — bidirectional library sync (arr_sync.sh)
|
||||
# Step 8: Conf push — push master.conf + setup state to all listed hosts
|
||||
#
|
||||
# ==============================================================================================
|
||||
# DESIGN PRINCIPLES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Credentials never in SSH command strings
|
||||
# Auth stack containers hold API keys, DB passwords, etc. The deploy script is written
|
||||
# locally, SCPed to the remote, and executed there. Command-line args are never used
|
||||
# to pass credentials — they'd appear in `ps` output and shell history on both servers.
|
||||
#
|
||||
# XML templates are the single source of truth for deployed containers
|
||||
# The owner's templates-user/ XMLs define every container deployed on the mirror.
|
||||
# The same XMLs that Unraid's Docker Manager uses are what get SCPed — the mirror's
|
||||
# Docker Manager can manage the containers after onboard without additional config.
|
||||
#
|
||||
# Dependency ordering in the auth stack is owner-enforced
|
||||
# PARTNERSHIP_AUTH_STACK order matters: Mariadb and Redis must come before Authelia.
|
||||
# The array is ordered correctly in host1.conf. After each Mariadb/Redis deploy,
|
||||
# the script waits for the container to be healthy before continuing. This is a remote
|
||||
# health check — the container must be running (or report healthy) before the next
|
||||
# dependent is deployed.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Root check
|
||||
# All operations run as root — SSH key management, docker operations, conf updates.
|
||||
#
|
||||
# SSH timeout on all remote calls
|
||||
# Every ssh/scp call uses SSH_TIMEOUT. No operation hangs indefinitely on a
|
||||
# slow or unreachable mirror.
|
||||
#
|
||||
# --dry-run shows exact actions without executing
|
||||
# Every step prints what it would do. SCP, deploy, plugin install, arr sync —
|
||||
# all dry-run safe.
|
||||
#
|
||||
# Step skip flags for partial re-runs
|
||||
# --skip-ssh, --skip-auth-stack, --skip-arr-stack, --skip-arr-sync allow
|
||||
# resuming after a partial failure without re-running completed steps.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION
|
||||
# ==============================================================================================
|
||||
#
|
||||
# host*.conf
|
||||
#
|
||||
# HOST*_PARTNERSHIP_AUTH_STACK
|
||||
# XML filenames (from this server's templates-user/) to push and deploy on the
|
||||
# mirror as its auth stack. Order matters: database deps before Authelia.
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_AUTH_STACK
|
||||
#
|
||||
# HOST*_PARTNERSHIP_REPLACE_CONTAINERS
|
||||
# Containers to stop on the mirror before deploying the auth stack.
|
||||
# Defined in the MIRROR's own conf (host*.conf on HOST2) — never in HOST1's conf.
|
||||
# Read live from the mirror via SSH during Step 3 (sources mirror's load_config.sh at
|
||||
# the same $SCRIPTS_ROOT path — convention: both servers use the same repo location).
|
||||
# Leave empty on HOST2 if no conflicting containers exist (fresh mirror: nothing to stop).
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_REPLACE_CONTAINERS (on the mirror)
|
||||
#
|
||||
# HOST*_PARTNERSHIP_ARR_STACK
|
||||
# XML filenames to push and deploy on the mirror as its arr stack.
|
||||
# Leave empty to skip arr stack deploy.
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_ARR_STACK
|
||||
#
|
||||
# HOST*_PARTNERSHIP_ARR_REPLACE_CONTAINERS
|
||||
# Arr containers to stop on the mirror before deploying the arr stack.
|
||||
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
|
||||
# Aliased by detect_hosts() → PARTNERSHIP_ARR_REPLACE_CONTAINERS (on the mirror)
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Partnership/partnership_onboard.sh
|
||||
# Full onboard — role detected automatically
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --dry-run
|
||||
# Preview all steps without making changes
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --log
|
||||
# Verbose per-step output
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-ssh
|
||||
# Skip SSH key setup (key already in place)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-auth-stack
|
||||
# Skip auth stack stop + deploy (Steps 3-4)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-arr-stack
|
||||
# Skip arr stack stop + deploy (Steps 5-6)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --skip-arr-sync
|
||||
# Skip arr library bootstrap (Step 8)
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --phase1-only
|
||||
# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.
|
||||
# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.
|
||||
#
|
||||
# Partnership/partnership_onboard.sh --phase2-only
|
||||
# OWNER only: container deploy + arr + onboard (skips SSH). Triggered automatically
|
||||
# by HOST2 after it completes its Mirror-path onboard. Can also be run manually.
|
||||
# Writes HOST2_PHASE2_DONE=true to varaverk_setup.db.
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPTS_ROOT="$SCRIPT_DIR/.."
|
||||
SSH_TIMEOUT=15
|
||||
|
||||
source "$SCRIPTS_ROOT/load_config.sh"
|
||||
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
|
||||
|
||||
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
|
||||
SKIP_SSH=false
|
||||
SKIP_AUTH_STACK=false
|
||||
SKIP_ARR_STACK=false
|
||||
SKIP_ARR_SYNC=false
|
||||
PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)
|
||||
PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)
|
||||
FILTERED_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-ssh) SKIP_SSH=true ;;
|
||||
--skip-auth-stack) SKIP_AUTH_STACK=true ;;
|
||||
--skip-arr-stack) SKIP_ARR_STACK=true ;;
|
||||
--skip-arr-sync) SKIP_ARR_SYNC=true ;;
|
||||
--phase1-only) PHASE1_ONLY=true ;;
|
||||
--phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;
|
||||
*) FILTERED_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
parse_args "${FILTERED_ARGS[@]}"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
|
||||
|
||||
acquire_lock
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
error "Docker command not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_hosts
|
||||
|
||||
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
|
||||
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
|
||||
OWNER="${!OWNER_ID}"
|
||||
MIRROR="${!MIRROR_ID}"
|
||||
# SSH_KEY (set by detect_hosts) is this server's own private key.
|
||||
# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.
|
||||
# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's
|
||||
# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.
|
||||
MIRROR_SSH_KEY="$SSH_KEY"
|
||||
|
||||
AM_OWNER=false
|
||||
AM_MIRROR=false
|
||||
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
|
||||
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
|
||||
|
||||
EXTRA_FLAGS=()
|
||||
[[ "$DRY_RUN" == true ]] && EXTRA_FLAGS+=("--dry-run")
|
||||
[[ "$LOG_MODE" == true ]] && EXTRA_FLAGS+=("--log")
|
||||
|
||||
START=$(date +%s)
|
||||
|
||||
# ── Helper: write phase completion flag to setup.db + push to remotes ─────────────────────────
|
||||
write_onboard_phase() {
|
||||
local target_id="$1" phase="$2"
|
||||
local key="${target_id}_PHASE${phase}_DONE"
|
||||
local state_file="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
|
||||
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
|
||||
if grep -q "^${key}=" "$state_file" 2>/dev/null; then
|
||||
sed -i "s|^${key}=.*|${key}=true|" "$state_file"
|
||||
else
|
||||
echo "${key}=true" >> "$state_file"
|
||||
fi
|
||||
command -v php &>/dev/null && \
|
||||
php -r "require_once '/usr/local/emhttp/plugins/varaverk/include/config.php'; vv_push_setup_state();" 2>/dev/null || true
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
echo ""
|
||||
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
|
||||
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
|
||||
echo ""
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
|
||||
#
|
||||
# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers
|
||||
# use the same convention), and reads the named config array from the mirror's own conf.
|
||||
# HOST2's container list stays in HOST2's host2.conf — not duplicated in HOST1's conf.
|
||||
# Fails gracefully if scripts aren't present yet or the array is empty (nothing to stop).
|
||||
#
|
||||
# deploy_container_from_xml() already stops/removes containers with the same name as what's
|
||||
# being deployed. This step handles containers with DIFFERENT names that conflict.
|
||||
# ==============================================================================================
|
||||
stop_mirror_stack() {
|
||||
local config_var="$1" label="$2"
|
||||
local -a to_stop=()
|
||||
|
||||
mapfile -t to_stop < <(
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
|
||||
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
|
||||
detect_hosts 2>/dev/null
|
||||
printf '%s\n' \"\${${config_var}[@]:-}\"" 2>/dev/null | grep -v '^$'
|
||||
)
|
||||
|
||||
if [[ ${#to_stop[@]} -eq 0 ]]; then
|
||||
log "No $label containers to stop on $MIRROR — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Stopping $label on $MIRROR: ${to_stop[*]}"
|
||||
for container in "${to_stop[@]}"; do
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would stop + rm $container on $MIRROR"
|
||||
continue
|
||||
fi
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
|
||||
"docker stop '$container' 2>/dev/null
|
||||
docker rm '$container' 2>/dev/null && echo removed" 2>/dev/null | \
|
||||
grep -q removed && \
|
||||
log " $container removed ✅" || \
|
||||
log " $container not found on $MIRROR — skipping"
|
||||
done
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
if [[ "$AM_MIRROR" == true ]]; then
|
||||
echo "━━━ Step 1/2 — SSH Key Setup (Mirror) ━━━"
|
||||
echo ""
|
||||
echo " Mirror sets up SSH keys, then notifies Owner to run Phase 2."
|
||||
echo ""
|
||||
|
||||
if [[ "$SKIP_SSH" == true ]]; then
|
||||
warn "Skipping SSH setup (--skip-ssh)"
|
||||
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
log "SSH key ready ✅"
|
||||
else
|
||||
error "SSH key setup failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━"
|
||||
echo ""
|
||||
|
||||
OWNER_IP=$(resolve_tailscale_ip "$OWNER" 2>/dev/null || true)
|
||||
PHASE2_TRIGGERED=false
|
||||
|
||||
if [[ -n "$OWNER_IP" ]]; then
|
||||
# Read OWNER's SCRIPTS_DIR from their varaverk.cfg — don't assume same path as mirror
|
||||
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
|
||||
'grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null | cut -d= -f2 | tr -d "\"'"'"'" 2>/dev/null' 2>/dev/null | tr -d '[:space:]')
|
||||
OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-/boot/config/plugins/varaverk}"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
|
||||
PHASE2_TRIGGERED=true
|
||||
elif timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
|
||||
"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered" \
|
||||
2>/dev/null | grep -q triggered; then
|
||||
log "Phase 2 triggered on $OWNER ✅"
|
||||
log "Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log"
|
||||
PHASE2_TRIGGERED=true
|
||||
else
|
||||
warn "Could not auto-trigger Phase 2 on $OWNER"
|
||||
fi
|
||||
else
|
||||
warn "Cannot resolve $OWNER Tailscale IP"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY MIRROR SETUP COMPLETE ━━━━━"
|
||||
echo " SSH key: ready"
|
||||
echo " Phase 2 on $OWNER: $( [[ "$PHASE2_TRIGGERED" == true ]] && echo "triggered ✅" || echo "needs manual trigger ⚠" )"
|
||||
if [[ "$PHASE2_TRIGGERED" == false ]]; then
|
||||
echo ""
|
||||
echo " Run manually on $OWNER:"
|
||||
echo " bash Partnership/partnership_onboard.sh --phase2-only"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
|
||||
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
|
||||
log "Mirror: $MIRROR ($MIRROR_IP)"
|
||||
[[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)"
|
||||
[[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)"
|
||||
echo ""
|
||||
|
||||
STEP_SSH_OK=false
|
||||
STEP_STOP_AUTH_OK=true
|
||||
STEP_AUTH_OK=true
|
||||
AUTH_DEPLOYED=0
|
||||
AUTH_FAILED=0
|
||||
STEP_STOP_ARR_OK=true
|
||||
STEP_ARR_OK=true
|
||||
ARR_DEPLOYED=0
|
||||
ARR_FAILED=0
|
||||
ONBOARD_OK=false
|
||||
ARR_SYNC_OK=false
|
||||
MASTER_PUSH_OK=false
|
||||
|
||||
# ── Step 1: SSH ───────────────────────────────────────────────────────────────────────────────
|
||||
# Skipped when --phase2-only (SSH was already done in Phase 1).
|
||||
echo "━━━ Step 1 — SSH Key Setup ━━━"
|
||||
|
||||
if [[ "$SKIP_SSH" == true ]]; then
|
||||
warn "Skipping (--skip-ssh)"
|
||||
STEP_SSH_OK=true
|
||||
elif [[ "$PHASE1_ONLY" == true ]]; then
|
||||
# Phase 1 in background: test if SSH already works first — avoids ssh-copy-id
|
||||
# hanging for a password prompt with no TTY.
|
||||
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" exit 0 2>/dev/null; then
|
||||
log "SSH to $MIRROR already works ✅ — skipping key install"
|
||||
STEP_SSH_OK=true
|
||||
else
|
||||
# Key not yet on HOST2 — try ssh_setup.sh (works interactively, may fail in background)
|
||||
if bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
log "SSH keys ready ✅"
|
||||
STEP_SSH_OK=true
|
||||
else
|
||||
# Soft-fail: generate key locally if not present, then tell user to install manually
|
||||
warn "Could not install key on $MIRROR automatically (no terminal for password prompt)"
|
||||
if [[ -f "$SSH_KEY" ]]; then
|
||||
log "Local key exists at: $SSH_KEY"
|
||||
else
|
||||
bash "$SCRIPT_DIR/ssh_setup.sh" --key-only "${EXTRA_FLAGS[@]}" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -f "${SSH_KEY}.pub" ]]; then
|
||||
echo ""
|
||||
echo " Install this key on $MIRROR to complete SSH setup:"
|
||||
echo " ┌─────────────────────────────────────────────────────"
|
||||
cat "${SSH_KEY}.pub" | sed 's/^/ │ /'
|
||||
echo " └─────────────────────────────────────────────────────"
|
||||
echo " Run on a terminal: ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
|
||||
echo " Then click 'Push Conf' in the Partnership tab."
|
||||
# Write key-ready flag so UI can show the manual-install state
|
||||
[[ "$DRY_RUN" == false ]] && {
|
||||
local kflag="${MIRROR_ID}_KEY_READY"
|
||||
local _setup_f="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
|
||||
grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
|
||||
&& sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|
||||
|| echo "${kflag}=true" >> "$_setup_f"
|
||||
}
|
||||
fi
|
||||
STEP_SSH_OK=false
|
||||
fi
|
||||
fi
|
||||
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
log "SSH keys ready ✅"
|
||||
STEP_SSH_OK=true
|
||||
else
|
||||
error "SSH key setup failed — aborting"
|
||||
error "Re-run or use --skip-ssh if key is already set up"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Phase 1 exit point ────────────────────────────────────────────────────────────────────────
|
||||
# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.
|
||||
# HOST2's wizard will detect the pushed master.conf + state file and take the correct path.
|
||||
if [[ "$PHASE1_ONLY" == true ]]; then
|
||||
if [[ "$STEP_SSH_OK" == false ]]; then
|
||||
# SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.
|
||||
# UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
|
||||
echo ""
|
||||
echo "━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━"
|
||||
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
|
||||
warn "Local setup had issues — check partnership_manager.sh output above"
|
||||
|
||||
END=$(date +%s)
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━"
|
||||
echo " SSH keys: key generated ✅ — NOT yet installed on $MIRROR ⚠"
|
||||
echo " Conf push: skipped (needs SSH access to $MIRROR)"
|
||||
echo " HOST1 setup: done ✅"
|
||||
echo " Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
echo " ACTION NEEDED: install the key on $MIRROR:"
|
||||
echo " ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
|
||||
echo " Then click 'Push Conf' in Partnership tab, or run:"
|
||||
echo " bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━ Phase 1 — Conf Push ━━━"
|
||||
|
||||
CONF_PUSH_OK=false
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would push master.conf + state file to $MIRROR"
|
||||
CONF_PUSH_OK=true
|
||||
elif ! command -v php &>/dev/null; then
|
||||
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
|
||||
else
|
||||
push_output=$(php -r "
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
|
||||
\$results = vv_push_master_conf();
|
||||
vv_push_setup_state();
|
||||
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
|
||||
\$failed = 0;
|
||||
foreach (\$results as \$r) {
|
||||
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
|
||||
if (!\$r['ok']) \$failed++;
|
||||
}
|
||||
exit(\$failed > 0 ? 1 : 0);
|
||||
" 2>/dev/null)
|
||||
push_rc=$?
|
||||
echo "$push_output"
|
||||
if [[ $push_rc -eq 0 ]]; then
|
||||
log "Conf push complete ✅"
|
||||
CONF_PUSH_OK=true
|
||||
else
|
||||
warn "Conf push had failures — retry via Scheduler → master.conf → Save Conf"
|
||||
fi
|
||||
fi
|
||||
|
||||
# HOST1 local setup — runs immediately without needing HOST2
|
||||
echo ""
|
||||
echo "━━━ Phase 1 — HOST1 Local Setup ━━━"
|
||||
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
|
||||
warn "Local setup had issues — check partnership_manager.sh output above"
|
||||
|
||||
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
|
||||
|
||||
END=$(date +%s)
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━"
|
||||
echo " SSH keys: $( [[ "$STEP_SSH_OK" == true ]] && echo "ready ✅" || echo "skipped" )"
|
||||
echo " Conf push: $( [[ "$CONF_PUSH_OK" == true ]] && echo "done ✅" || echo "⚠ manual needed" )"
|
||||
echo " HOST1 setup: done ✅"
|
||||
echo " Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
|
||||
echo " The wizard will detect the pushed conf and take the correct path."
|
||||
echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Step 2: Stop mirror's existing auth stack ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 2 — Stop Mirror Auth Stack ━━━"
|
||||
|
||||
if [[ "$SKIP_AUTH_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-auth-stack)"
|
||||
else
|
||||
stop_mirror_stack "PARTNERSHIP_REPLACE_CONTAINERS" "auth stack"
|
||||
fi
|
||||
|
||||
# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 3 — Deploy Auth Stack on Mirror ━━━"
|
||||
|
||||
if [[ "$SKIP_AUTH_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-auth-stack)"
|
||||
elif [[ ${#PARTNERSHIP_AUTH_STACK[@]} -eq 0 ]]; then
|
||||
warn "PARTNERSHIP_AUTH_STACK not set in ${MY_ID} conf — skipping auth stack deploy"
|
||||
warn "Add HOST${MY_ID: -1}_PARTNERSHIP_AUTH_STACK to host${MY_ID: -1}.conf"
|
||||
STEP_AUTH_OK=false
|
||||
else
|
||||
deploy_xml_stack PARTNERSHIP_AUTH_STACK
|
||||
AUTH_DEPLOYED=$_STACK_DEPLOYED
|
||||
AUTH_FAILED=$_STACK_FAILED
|
||||
echo "Auth stack: $AUTH_DEPLOYED deployed, $AUTH_FAILED failed"
|
||||
[[ "$AUTH_FAILED" -gt 0 ]] && STEP_AUTH_OK=false
|
||||
fi
|
||||
|
||||
# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 4 — Stop Mirror Arr Stack ━━━"
|
||||
|
||||
if [[ "$SKIP_ARR_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-arr-stack)"
|
||||
elif [[ ${#PARTNERSHIP_ARR_STACK[@]} -eq 0 ]]; then
|
||||
log "PARTNERSHIP_ARR_STACK not configured — skipping arr stack deploy"
|
||||
SKIP_ARR_STACK=true
|
||||
else
|
||||
stop_mirror_stack "PARTNERSHIP_ARR_REPLACE_CONTAINERS" "arr stack"
|
||||
fi
|
||||
|
||||
# ── Step 6: Deploy arr stack on mirror ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 5 — Deploy Arr Stack on Mirror ━━━"
|
||||
|
||||
if [[ "$SKIP_ARR_STACK" == true ]]; then
|
||||
warn "Skipping (--skip-arr-stack)"
|
||||
else
|
||||
deploy_xml_stack PARTNERSHIP_ARR_STACK
|
||||
ARR_DEPLOYED=$_STACK_DEPLOYED
|
||||
ARR_FAILED=$_STACK_FAILED
|
||||
echo "Arr stack: $ARR_DEPLOYED deployed, $ARR_FAILED failed"
|
||||
[[ "$ARR_FAILED" -gt 0 ]] && STEP_ARR_OK=false
|
||||
fi
|
||||
|
||||
# ── Step 7: Partnership onboard ───────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 6 — Partnership Onboard ━━━"
|
||||
|
||||
if bash "$SCRIPTS_ROOT/Partnership/partnership_manager.sh" --onboard "${EXTRA_FLAGS[@]}"; then
|
||||
echo "Partnership onboard complete ✅"
|
||||
ONBOARD_OK=true
|
||||
else
|
||||
error "Partnership onboard failed"
|
||||
ONBOARD_OK=false
|
||||
fi
|
||||
|
||||
# ── Step 8: Arr library bootstrap ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━ Step 7 — Arr Library Bootstrap ━━━"
|
||||
|
||||
if [[ "$ONBOARD_OK" == false ]]; then
|
||||
warn "Skipping — onboard did not complete"
|
||||
elif [[ "$SKIP_ARR_SYNC" == true ]]; then
|
||||
warn "Skipping (--skip-arr-sync)"
|
||||
elif [[ ! -f "$SCRIPTS_ROOT/Media/arr_sync.sh" ]]; then
|
||||
warn "arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live"
|
||||
elif bash "$SCRIPTS_ROOT/Media/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
|
||||
echo "Arr bootstrap complete ✅"
|
||||
ARR_SYNC_OK=true
|
||||
else
|
||||
warn "Arr sync had errors — partnership still valid"
|
||||
warn "Re-run Media/arr_sync.sh once all arr containers are live"
|
||||
fi
|
||||
|
||||
# ── Step 9: Push master.conf to all listed hosts ──────────────────────────────────────────────
|
||||
# SSH is now established and all partners have the plugin installed.
|
||||
# Push the authoritative master.conf so every listed host is in sync immediately.
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 8 — master.conf Push ━━━"
|
||||
|
||||
if [[ "$ONBOARD_OK" == false ]]; then
|
||||
warn "Skipping — onboard did not complete"
|
||||
elif [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would push master.conf to all listed hosts"
|
||||
MASTER_PUSH_OK=true
|
||||
elif ! command -v php &>/dev/null; then
|
||||
warn "php not available — push master.conf manually via Scheduler → master.conf → Save Conf"
|
||||
else
|
||||
push_output=$(php -r "
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/config.php';
|
||||
\$results = vv_push_master_conf();
|
||||
vv_push_setup_state();
|
||||
if (empty(\$results)) { echo 'no remote hosts'; exit(0); }
|
||||
\$failed = 0;
|
||||
foreach (\$results as \$r) {
|
||||
echo \$r['host'] . ': ' . (\$r['ok'] ? 'pushed' : 'FAILED — ' . \$r['error']) . PHP_EOL;
|
||||
if (!\$r['ok']) \$failed++;
|
||||
}
|
||||
exit(\$failed > 0 ? 1 : 0);
|
||||
" 2>/dev/null)
|
||||
push_rc=$?
|
||||
echo "$push_output"
|
||||
if [[ $push_rc -eq 0 ]]; then
|
||||
echo "master.conf sync complete ✅"
|
||||
MASTER_PUSH_OK=true
|
||||
else
|
||||
warn "master.conf push had failures — retry via Scheduler → master.conf → Save Conf"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────
|
||||
[[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
|
||||
END=$(date +%s)
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━"
|
||||
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo " Mirror: $MIRROR ($MIRROR_IP)"
|
||||
[[ "$PHASE2_ONLY" == true ]] && echo " Mode: Phase 2 (triggered by HOST2 notification)"
|
||||
echo " Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
|
||||
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
|
||||
|
||||
echo " Step 1 — SSH keys: $(_skip "$SKIP_SSH" "$STEP_SSH_OK")"
|
||||
echo " Step 2 — Stop auth: $(_skip "$SKIP_AUTH_STACK" "$STEP_STOP_AUTH_OK")"
|
||||
echo " Step 3 — Auth stack: $( [[ "$SKIP_AUTH_STACK" == true ]] && echo "skipped" || echo "${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed" )"
|
||||
echo " Step 4 — Stop arr: $(_skip "$SKIP_ARR_STACK" "$STEP_STOP_ARR_OK")"
|
||||
echo " Step 5 — Arr stack: $( [[ "$SKIP_ARR_STACK" == true ]] && echo "skipped" || echo "${ARR_DEPLOYED} deployed, ${ARR_FAILED} failed" )"
|
||||
echo " Step 6 — Onboard: $(_ok "$ONBOARD_OK")"
|
||||
echo " Step 7 — Arr bootstrap: $( [[ "$SKIP_ARR_SYNC" == true || "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$ARR_SYNC_OK")" )"
|
||||
echo " Step 8 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )"
|
||||
echo ""
|
||||
|
||||
if [[ "$ONBOARD_OK" == true ]]; then
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
|
||||
echo "$ICON_DONE DONE — partnership established ✅"
|
||||
echo "Verify with: Partnership/partnership_manager.sh --status"
|
||||
else
|
||||
error "Setup incomplete — resolve errors above and re-run"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
[[ "$ONBOARD_OK" == false ]] && exit 1
|
||||
exit 0
|
||||
@@ -0,0 +1,192 @@
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 🤝 PARTNERSHIP
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**Managed lifecycle for a two-server partnership — setup, ongoing operation,
|
||||
and clean separation.** One server owns the shared services. The other mirrors
|
||||
them and benefits from them. Every phase of the relationship has the same
|
||||
engineering discipline as the rest of the ecosystem.
|
||||
|
||||
> **This folder exists because a clean exit should be as easy as a clean setup.**
|
||||
> The partnership is not a permanent commitment. `--offboard` works from either
|
||||
> server at any time. Everything the mirror needs to run independently is already
|
||||
> there. The only thing that stops on separation is the sync — and that's intentional.
|
||||
|
||||
---
|
||||
|
||||
## ━━━ THE PROBLEM THAT BUILT THIS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Two Servers, One Auth Stack, No Clean Way to Share It
|
||||
|
||||
The auth stack — NginxProxyManager, LLDAP, Authelia, MariaDB, Redis — runs on
|
||||
HOST1. HOST2 serves its own domain to its own household. It needs its own auth.
|
||||
But maintaining two independent auth stacks means double the work: two places to
|
||||
add users, two places to update proxy rules, two places to renew certs, two
|
||||
configurations that inevitably drift apart. One change on HOST1 has to be manually
|
||||
replicated to HOST2 — or it isn't, and the configurations diverge silently.
|
||||
|
||||
The real cost isn't the initial setup. It's the maintenance burden that accumulates
|
||||
over months — every new user, every proxy rule change, every config update applied
|
||||
in one place and forgotten in the other.
|
||||
|
||||
**The fix:** one auth stack with a managed mirror. HOST1 owns the configuration.
|
||||
HOST2 runs a warm copy that stays current via 30-minute sync. HOST2's operator makes
|
||||
zero auth management decisions — clicking an auth container opens HOST1's WebUI via
|
||||
Tailscale. Changes happen there, propagate to HOST2 in 30 minutes. One place to
|
||||
manage everything for both households.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 No Structure Around the Relationship Itself
|
||||
|
||||
Setting up the mirror was a manual process. SSH in, reconfigure container WebUI
|
||||
URLs one by one, copy auth config, verify connectivity, update state tracking. No
|
||||
defined sequence. No dry-run capability. No verification that each step worked. If
|
||||
something went wrong midway, the mirror was in an inconsistent state with no clear
|
||||
way to understand what had and hadn't been done. Offboard was worse — it involves
|
||||
stopping a sync that's been running for months, making a final copy of data,
|
||||
reconfiguring WebUIs back to local addresses, removing Tailscale access, and
|
||||
notifying both servers. A manual process with that many steps, taken under pressure,
|
||||
leaves one or both parties in a bad state.
|
||||
|
||||
**The fix:** `partnership_manager.sh` with explicit modes for each lifecycle phase.
|
||||
Each mode is a defined sequence. Every step is verified. Dry-run shows exactly what
|
||||
will happen before anything changes. State files make the current relationship status
|
||||
unambiguous from either server.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 No Safe Way to Check If the Other Server Has Gone Away
|
||||
|
||||
After months of operation, HOST2 goes quiet. The sync starts failing. The offline
|
||||
counter increments. But nothing actually happens — the ecosystem just keeps failing
|
||||
the same sync, incrementing the same counter, sending the same notifications.
|
||||
Without a defined threshold and an automated response, "partner gone for 30 days"
|
||||
looks exactly like "partner gone for 3 years."
|
||||
|
||||
**The fix:** `PARTNERSHIP_OFFLINE_THRESHOLD`. After this many days of missed sync
|
||||
cycles, both servers independently auto-offboard. HOST1 removes HOST2 from Tailscale,
|
||||
disables critical sync, writes INACTIVE state. HOST2 — if it eventually comes back —
|
||||
reads HOST1's INACTIVE state and cleans up its own side. The relationship is formally
|
||||
ended from both sides without anyone needing to be present.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Ownership Transfer Had No Safe Path
|
||||
|
||||
The arrangement was always intended to be flexible — HOST1 owns the auth stack now,
|
||||
but circumstances change. Swapping ownership manually meant reconfiguring WebUIs on
|
||||
both servers, swapping sync direction, updating master.conf on both, and hoping the
|
||||
sequence was correct. A misstep — like flipping sync direction before the final sync
|
||||
completed — leaves both servers with different auth configurations and no clear source
|
||||
of truth.
|
||||
|
||||
**The fix:** `--transfer` with a required confirmation string, a consecutive health
|
||||
check system, and a strict sequence. The confirmation string cannot be typed
|
||||
accidentally. Health strikes require both servers to be healthy on multiple
|
||||
consecutive checks before the transfer begins. A final sync in the current direction
|
||||
runs before anything is flipped.
|
||||
|
||||
---
|
||||
|
||||
## ━━━ WHAT THIS FOLDER DOES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**Onboarding** (`partnership_onboard.sh`) — one-time setup run from both servers.
|
||||
Generates SSH keys, installs the auth stack and arr stack on the mirror from XML
|
||||
templates, configures WebUI redirects to the owner, and bootstraps the arr library.
|
||||
Role is detected automatically — no flags needed to declare which side you are.
|
||||
|
||||
**Offboarding** (`partnership_offboard.sh`) — handles clean separation from either
|
||||
role. Owner path: final sync, WebUI reconfigure, remote container + appdata cleanup
|
||||
(auth/arr stack by XML array, fallback coverage by naming), SSH key revocation,
|
||||
Tailscale removal. Mirror path: local WebUI reconfigure, remove owner-deployed
|
||||
containers locally, disable sync, revoke Emby admin, SSH key revocation, signal owner.
|
||||
Called by `partnership_manager.sh --offboard` but runnable directly.
|
||||
|
||||
**Lifecycle management** (`partnership_manager.sh`) — dispatcher and monitor.
|
||||
- Manually: `--onboard`, `--offboard` (delegates to offboard script), `--transfer`, `--status`, `--unblock`
|
||||
- Automatically: `--check` called every 30 minutes by `critical_sync_maintenance.sh`
|
||||
|
||||
**SSH management** (`ssh_setup.sh`) — generates the keypair for rsync automation,
|
||||
installs it on the remote, and tracks auth failures with a configurable strike system.
|
||||
Called by `partnership_onboard.sh` but runnable independently for validation and
|
||||
re-keying.
|
||||
|
||||
---
|
||||
|
||||
## ━━━ RELATIONSHIP TO OTHER FOLDERS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**Orchestrators/** — `critical_sync_maintenance.sh` calls `partnership_manager.sh --check`
|
||||
every 30 minutes, passing `--remote-seen` or `--remote-unseen` based on whether the
|
||||
rsync to the partner succeeded. The rsync outcome is the connectivity signal — no
|
||||
separate ping needed.
|
||||
|
||||
**Rsync/** — Critical-Data rsync keeps the auth stack appdata current on the mirror
|
||||
(NPM rules, Authelia config, LLDAP database, certs). Partnership manages the
|
||||
relationship; Rsync delivers the actual data. On offboard, `rsync_stop.sh --rsync-only`
|
||||
stops any running rsync before the final sync runs.
|
||||
|
||||
**Media/** — `arr_sync.sh` bootstraps the mirror's arr library during onboard, ensuring
|
||||
both servers have each other's full library from day one. Ongoing arr sync runs
|
||||
independently at 4-hour cadence.
|
||||
|
||||
---
|
||||
|
||||
## ━━━ SCRIPTS IN THIS FOLDER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
| Script | Role | When It Runs |
|
||||
|--------|------|--------------|
|
||||
| `partnership_onboard.sh` | One-time setup — SSH keys, stack deploy, arr bootstrap | Manually, once per server per partnership |
|
||||
| `partnership_offboard.sh` | Clean separation — both paths, both roles | Via `partnership_manager.sh --offboard`; or directly |
|
||||
| `partnership_manager.sh` | Dispatcher + monitor — onboard WebUIs, health check, transfer, status | `--check` every 30min; all other modes manually |
|
||||
| `ssh_setup.sh` | SSH key generation, remote install, auth validation | Called by onboard; manually for re-keying or validation |
|
||||
|
||||
---
|
||||
|
||||
## ━━━ HOW THE SCRIPTS RELATE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
```
|
||||
INITIAL SETUP (run once)
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
HOST2 (mirror) runs:
|
||||
partnership_onboard.sh
|
||||
└─ ssh_setup.sh generates keypair, copies to owner
|
||||
|
||||
HOST1 (owner) runs:
|
||||
partnership_onboard.sh
|
||||
├─ ssh_setup.sh generates keypair, copies to mirror
|
||||
├─ [plugin install on mirror] FolderView3 if configured
|
||||
├─ [stop mirror auth stack] PARTNERSHIP_REPLACE_CONTAINERS via SSH
|
||||
├─ deploy_container_from_xml() pushes auth XMLs to mirror + starts containers
|
||||
│ └─ wait_for_container_healthy() Mariadb/Redis health-checked before Authelia
|
||||
├─ [stop mirror arr stack] PARTNERSHIP_ARR_REPLACE_CONTAINERS via SSH
|
||||
├─ deploy_container_from_xml() pushes arr XMLs to mirror + starts containers
|
||||
├─ partnership_manager.sh --onboard reconfigures WebUIs, writes ACTIVE state
|
||||
└─ arr_sync.sh bootstraps full library on both servers
|
||||
|
||||
ONGOING OPERATION (every 30min)
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
critical_sync_maintenance.sh
|
||||
├─ Critical-Data rsync keeps auth appdata current on mirror
|
||||
└─ partnership_manager.sh --check reads state files, tracks offline counter
|
||||
├─ --remote-seen path rsync succeeded → reset counter
|
||||
└─ --remote-unseen path rsync failed → increment counter → auto-offboard at threshold
|
||||
|
||||
OFFBOARD (manual or auto)
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
partnership_manager.sh --offboard
|
||||
└─ partnership_offboard.sh (exec'd — holds own lock)
|
||||
├─ [owner-initiated] stop rsync → final sync → reconfigure mirror WebUIs
|
||||
│ → disable critical sync → write INACTIVE state
|
||||
│ → local fallback cleanup → restart own stack
|
||||
│ → remote: remove auth/arr stack + fallback containers + appdata
|
||||
│ → restart mirror stack → Emby revoke → SSH key revocation
|
||||
│ → Tailscale removal after grace window
|
||||
└─ [mirror-initiated] stop rsync → reconfigure own WebUIs
|
||||
→ remove owner-deployed containers locally (reads owner's stack arrays)
|
||||
→ remove local fallback containers → disable critical sync
|
||||
→ revoke own Emby admin → restart own stack
|
||||
→ SSH key revocation → write INACTIVE → signal owner
|
||||
```
|
||||
@@ -0,0 +1,191 @@
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 🤝 PARTNERSHIP
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**Managed lifecycle for a two-server partnership — setup, ongoing operation,
|
||||
and clean separation.** One server owns the shared services. The other mirrors
|
||||
them and benefits from them. Every phase of the relationship has the same
|
||||
engineering discipline as the rest of the ecosystem.
|
||||
|
||||
> **This folder exists because a clean exit should be as easy as a clean setup.**
|
||||
> The partnership is not a permanent commitment. `--offboard` works from either
|
||||
> server at any time. Everything the mirror needs to run independently is already
|
||||
> there. The only thing that stops on separation is the sync — and that's intentional.
|
||||
|
||||
---
|
||||
|
||||
## ━━━ THE PROBLEM THAT BUILT THIS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Two Servers, One Auth Stack, No Clean Way to Share It
|
||||
|
||||
The auth stack — NginxProxyManager, LLDAP, Authelia, MariaDB, Redis — runs on
|
||||
HOST1. HOST2 serves its own domain to its own household. It needs its own auth.
|
||||
But maintaining two independent auth stacks means double the work: two places to
|
||||
add users, two places to update proxy rules, two places to renew certs, two
|
||||
configurations that inevitably drift apart. One change on HOST1 has to be manually
|
||||
replicated to HOST2 — or it isn't, and the configurations diverge silently.
|
||||
|
||||
The real cost isn't the initial setup. It's the maintenance burden that accumulates
|
||||
over months — every new user, every proxy rule change, every config update applied
|
||||
in one place and forgotten in the other.
|
||||
|
||||
**The fix:** one auth stack with a managed mirror. HOST1 owns the configuration.
|
||||
HOST2 runs a warm copy that stays current via 30-minute sync. HOST2's operator makes
|
||||
zero auth management decisions — clicking an auth container opens HOST1's WebUI via
|
||||
Tailscale. Changes happen there, propagate to HOST2 in 30 minutes. One place to
|
||||
manage everything for both households.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 No Structure Around the Relationship Itself
|
||||
|
||||
Setting up the mirror was a manual process. SSH in, reconfigure container WebUI
|
||||
URLs one by one, copy auth config, verify connectivity, update state tracking. No
|
||||
defined sequence. No dry-run capability. No verification that each step worked. If
|
||||
something went wrong midway, the mirror was in an inconsistent state with no clear
|
||||
way to understand what had and hadn't been done. Offboard was worse — it involves
|
||||
stopping a sync that's been running for months, making a final copy of data,
|
||||
reconfiguring WebUIs back to local addresses, removing Tailscale access, and
|
||||
notifying both servers. A manual process with that many steps, taken under pressure,
|
||||
leaves one or both parties in a bad state.
|
||||
|
||||
**The fix:** `partnership_manager.sh` with explicit modes for each lifecycle phase.
|
||||
Each mode is a defined sequence. Every step is verified. Dry-run shows exactly what
|
||||
will happen before anything changes. State files make the current relationship status
|
||||
unambiguous from either server.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 No Safe Way to Check If the Other Server Has Gone Away
|
||||
|
||||
After months of operation, HOST2 goes quiet. The sync starts failing. The offline
|
||||
counter increments. But nothing actually happens — the ecosystem just keeps failing
|
||||
the same sync, incrementing the same counter, sending the same notifications.
|
||||
Without a defined threshold and an automated response, "partner gone for 30 days"
|
||||
looks exactly like "partner gone for 3 years."
|
||||
|
||||
**The fix:** `PARTNERSHIP_OFFLINE_THRESHOLD`. After this many days of missed sync
|
||||
cycles, both servers independently auto-offboard. HOST1 removes HOST2 from Tailscale,
|
||||
disables critical sync, writes INACTIVE state. HOST2 — if it eventually comes back —
|
||||
reads HOST1's INACTIVE state and cleans up its own side. The relationship is formally
|
||||
ended from both sides without anyone needing to be present.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Ownership Transfer Had No Safe Path
|
||||
|
||||
The arrangement was always intended to be flexible — HOST1 owns the auth stack now,
|
||||
but circumstances change. Swapping ownership manually meant reconfiguring WebUIs on
|
||||
both servers, swapping sync direction, updating master.conf on both, and hoping the
|
||||
sequence was correct. A misstep — like flipping sync direction before the final sync
|
||||
completed — leaves both servers with different auth configurations and no clear source
|
||||
of truth.
|
||||
|
||||
**The fix:** `--transfer` with a required confirmation string, a consecutive health
|
||||
check system, and a strict sequence. The confirmation string cannot be typed
|
||||
accidentally. Health strikes require both servers to be healthy on multiple
|
||||
consecutive checks before the transfer begins. A final sync in the current direction
|
||||
runs before anything is flipped.
|
||||
|
||||
---
|
||||
|
||||
## ━━━ WHAT THIS FOLDER DOES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**Onboarding** (`partnership_onboard.sh`) — one-time setup run from both servers.
|
||||
Generates SSH keys, installs the auth stack and arr stack on the mirror from XML
|
||||
templates, configures WebUI redirects to the owner, and bootstraps the arr library.
|
||||
Role is detected automatically — no flags needed to declare which side you are.
|
||||
|
||||
**Offboarding** (`partnership_offboard.sh`) — handles clean separation from either
|
||||
role. Owner path: final sync, WebUI reconfigure, remote container + appdata cleanup
|
||||
(auth/arr stack by XML array, fallback coverage by naming), SSH key revocation,
|
||||
Tailscale removal. Mirror path: local WebUI reconfigure, remove owner-deployed
|
||||
containers locally, disable sync, revoke Emby admin, SSH key revocation, signal owner.
|
||||
Called by `partnership_manager.sh --offboard` but runnable directly.
|
||||
|
||||
**Lifecycle management** (`partnership_manager.sh`) — dispatcher and monitor.
|
||||
- Manually: `--onboard`, `--offboard` (delegates to offboard script), `--transfer`, `--status`, `--unblock`
|
||||
- Automatically: `--check` called every 30 minutes by `critical_sync_maintenance.sh`
|
||||
|
||||
**SSH management** (`ssh_setup.sh`) — generates the keypair for rsync automation,
|
||||
installs it on the remote, and tracks auth failures with a configurable strike system.
|
||||
Called by `partnership_onboard.sh` but runnable independently for validation and
|
||||
re-keying.
|
||||
|
||||
---
|
||||
|
||||
## ━━━ RELATIONSHIP TO OTHER FOLDERS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**Orchestrators/** — `critical_sync_maintenance.sh` calls `partnership_manager.sh --check`
|
||||
every 30 minutes, passing `--remote-seen` or `--remote-unseen` based on whether the
|
||||
rsync to the partner succeeded. The rsync outcome is the connectivity signal — no
|
||||
separate ping needed.
|
||||
|
||||
**Rsync/** — Critical-Data rsync keeps the auth stack appdata current on the mirror
|
||||
(NPM rules, Authelia config, LLDAP database, certs). Partnership manages the
|
||||
relationship; Rsync delivers the actual data. On offboard, `rsync_stop.sh --rsync-only`
|
||||
stops any running rsync before the final sync runs.
|
||||
|
||||
**Media/** — `arr_sync.sh` bootstraps the mirror's arr library during onboard, ensuring
|
||||
both servers have each other's full library from day one. Ongoing arr sync runs
|
||||
independently at 4-hour cadence.
|
||||
|
||||
---
|
||||
|
||||
## ━━━ SCRIPTS IN THIS FOLDER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
| Script | Role | When It Runs |
|
||||
|--------|------|--------------|
|
||||
| `partnership_onboard.sh` | One-time setup — SSH keys, stack deploy, arr bootstrap | Manually, once per server per partnership |
|
||||
| `partnership_offboard.sh` | Clean separation — both paths, both roles | Via `partnership_manager.sh --offboard`; or directly |
|
||||
| `partnership_manager.sh` | Dispatcher + monitor — onboard WebUIs, health check, transfer, status | `--check` every 30min; all other modes manually |
|
||||
| `ssh_setup.sh` | SSH key generation, remote install, auth validation | Called by onboard; manually for re-keying or validation |
|
||||
|
||||
---
|
||||
|
||||
## ━━━ HOW THE SCRIPTS RELATE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
```
|
||||
INITIAL SETUP (run once)
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
HOST2 (mirror) runs:
|
||||
partnership_onboard.sh
|
||||
└─ ssh_setup.sh generates keypair, copies to owner
|
||||
|
||||
HOST1 (owner) runs:
|
||||
partnership_onboard.sh
|
||||
├─ ssh_setup.sh generates keypair, copies to mirror
|
||||
├─ [stop mirror auth stack] PARTNERSHIP_REPLACE_CONTAINERS via SSH
|
||||
├─ deploy_container_from_xml() pushes auth XMLs to mirror + starts containers
|
||||
│ └─ wait_for_container_healthy() Mariadb/Redis health-checked before Authelia
|
||||
├─ [stop mirror arr stack] PARTNERSHIP_ARR_REPLACE_CONTAINERS via SSH
|
||||
├─ deploy_container_from_xml() pushes arr XMLs to mirror + starts containers
|
||||
├─ partnership_manager.sh --onboard reconfigures WebUIs, writes ACTIVE state
|
||||
└─ arr_sync.sh bootstraps full library on both servers
|
||||
|
||||
ONGOING OPERATION (every 30min)
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
critical_sync_maintenance.sh
|
||||
├─ Critical-Data rsync keeps auth appdata current on mirror
|
||||
└─ partnership_manager.sh --check reads state files, tracks offline counter
|
||||
├─ --remote-seen path rsync succeeded → reset counter
|
||||
└─ --remote-unseen path rsync failed → increment counter → auto-offboard at threshold
|
||||
|
||||
OFFBOARD (manual or auto)
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
partnership_manager.sh --offboard
|
||||
└─ partnership_offboard.sh (exec'd — holds own lock)
|
||||
├─ [owner-initiated] stop rsync → final sync → reconfigure mirror WebUIs
|
||||
│ → disable critical sync → write INACTIVE state
|
||||
│ → local fallback cleanup → restart own stack
|
||||
│ → remote: remove auth/arr stack + fallback containers + appdata
|
||||
│ → restart mirror stack → Emby revoke → SSH key revocation
|
||||
│ → Tailscale removal after grace window
|
||||
└─ [mirror-initiated] stop rsync → reconfigure own WebUIs
|
||||
→ remove owner-deployed containers locally (reads owner's stack arrays)
|
||||
→ remove local fallback containers → disable critical sync
|
||||
→ revoke own Emby admin → restart own stack
|
||||
→ SSH key revocation → write INACTIVE → signal owner
|
||||
```
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Certificate Monitor ============================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# SSL certificate expiry monitoring for all configured domains. Scheduled weekly
|
||||
# (Sunday 9am). Connects via openssl directly to each domain — not to NPM's API,
|
||||
# not to any internal check, but to the actual TLS handshake the outside world sees.
|
||||
#
|
||||
# Per domain: HEALTHY (> CERT_WARN_DAYS remaining, silent) | WARNING (≤ CERT_WARN_DAYS)
|
||||
# | CRITICAL (≤ CERT_CRIT_DAYS) | FAILED (could not connect or parse cert).
|
||||
# Notifications batched by severity — one message lists all WARNING domains, a
|
||||
# separate message lists all CRITICAL domains. Not one notification per domain.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# DESIGN PRINCIPLES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Direct openssl, Not an API
|
||||
# API-based cert checks ask the certificate manager whether the cert is valid.
|
||||
# openssl checks ask the server what cert it is actually serving. These are not
|
||||
# the same question and the answers can differ. Catches: cert renewed in NPM but
|
||||
# server not reloaded (old cert still serving), wrong cert being served to external
|
||||
# clients, chain issues visible externally but not internally, NPM reporting healthy
|
||||
# while the outside world sees an expired cert.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Single Instance Lock
|
||||
# acquire_lock prevents concurrent runs producing duplicate notifications.
|
||||
#
|
||||
# Per-Host Domain List
|
||||
# detect_hosts() aliases HOST*_CERT_MONITOR_DOMAINS → CERT_MONITOR_DOMAINS.
|
||||
# Each server monitors its own domains only.
|
||||
#
|
||||
# Empty Array Guard
|
||||
# Warns and exits cleanly if CERT_MONITOR_DOMAINS is empty — no silent no-op.
|
||||
#
|
||||
# Connection Timeout
|
||||
# CERT_TIMEOUT caps each openssl connection attempt. One unreachable domain
|
||||
# does not block the remaining domains.
|
||||
#
|
||||
# Notification Validated
|
||||
# platform_require_cmd confirms openssl and notify script are present before use.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION
|
||||
# ==============================================================================================
|
||||
#
|
||||
# host*.conf
|
||||
#
|
||||
# HOST*_CERT_MONITOR_DOMAINS
|
||||
# Domains this host monitors. Each domain and subdomain is a separate entry —
|
||||
# they have independent certs. Aliased by detect_hosts() → CERT_MONITOR_DOMAINS.
|
||||
#
|
||||
# master.conf
|
||||
#
|
||||
# CERT_WARN_DAYS
|
||||
# Days before expiry at which to send a warning notification. (default: 30)
|
||||
#
|
||||
# CERT_CRIT_DAYS
|
||||
# Days before expiry at which to send a critical notification. (default: 7)
|
||||
#
|
||||
# CERT_TIMEOUT
|
||||
# Seconds to wait per domain before declaring FAILED. (default: 10)
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# cert_monitor.sh
|
||||
# Check all configured domains and notify on WARNING, CRITICAL, or FAILED.
|
||||
# Silent when all domains are healthy.
|
||||
#
|
||||
# cert_monitor.sh --dry-run
|
||||
# Check all domains and show results. No notifications sent regardless of result.
|
||||
#
|
||||
# cert_monitor.sh --status
|
||||
# Show domain list, warning thresholds, and timeout. Then exit.
|
||||
#
|
||||
# cert_monitor.sh --log
|
||||
# Verbose per-domain output during the run.
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Setup ━━━"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
error "Must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate openssl — required for all cert checks
|
||||
platform_require_cmd \
|
||||
"$(command -v openssl 2>/dev/null || echo /usr/bin/openssl)" \
|
||||
"version" "OpenSSL" \
|
||||
"openssl" || { error "openssl not found — required for certificate checks"; exit 1; }
|
||||
|
||||
platform_require_cmd \
|
||||
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
|
||||
"" "" \
|
||||
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
|
||||
|
||||
acquire_lock
|
||||
|
||||
# detect_hosts() sets MY_ID and aliases HOST*_CERT_MONITOR_DOMAINS
|
||||
detect_hosts
|
||||
|
||||
# Empty array guard
|
||||
if [[ ${#CERT_MONITOR_DOMAINS[@]} -eq 0 ]]; then
|
||||
warn "CERT_MONITOR_DOMAINS is empty for $MY_ID"
|
||||
warn "Check HOST*_CERT_MONITOR_DOMAINS in host*.conf"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Domains to check: ${#CERT_MONITOR_DOMAINS[@]}"
|
||||
log "$ICON_GEAR Config: warn=${CERT_WARN_DAYS}d crit=${CERT_CRIT_DAYS}d timeout=${CERT_TIMEOUT}s"
|
||||
log "$ICON_GEAR Domains: ${CERT_MONITOR_DOMAINS[*]}"
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — results shown but no notifications sent"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Status ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$SHOW_STATUS" == true ]]; then
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_CERT Domains: ${CERT_MONITOR_DOMAINS[*]}"
|
||||
echo "$ICON_WARN Warn at: ${CERT_WARN_DAYS} days remaining"
|
||||
echo "$ICON_ERROR Crit at: ${CERT_CRIT_DAYS} days remaining"
|
||||
echo "$ICON_TIME Timeout: ${CERT_TIMEOUT}s per domain"
|
||||
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ── CERT CHECK FUNCTION ───────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Connects to domain:443 via openssl, extracts expiry date, calculates days remaining.
|
||||
# Returns:
|
||||
# 0 = healthy (> CERT_WARN_DAYS)
|
||||
# 1 = warning (<= CERT_WARN_DAYS)
|
||||
# 2 = critical (<= CERT_CRIT_DAYS)
|
||||
# 3 = failed (could not connect or parse)
|
||||
|
||||
check_cert() {
|
||||
local domain="$1"
|
||||
local port="${2:-443}"
|
||||
|
||||
local expiry_str
|
||||
expiry_str=$(echo | timeout "$CERT_TIMEOUT" openssl s_client \
|
||||
-connect "${domain}:${port}" \
|
||||
-servername "$domain" \
|
||||
2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
|
||||
if [[ -z "$expiry_str" ]]; then
|
||||
error "$ICON_CERT $domain — could not retrieve certificate (unreachable or no TLS)"
|
||||
return 3
|
||||
fi
|
||||
|
||||
local expiry_epoch
|
||||
expiry_epoch=$(date -d "$expiry_str" +%s 2>/dev/null)
|
||||
|
||||
if [[ -z "$expiry_epoch" ]]; then
|
||||
error "$ICON_CERT $domain — could not parse expiry date: $expiry_str"
|
||||
return 3
|
||||
fi
|
||||
|
||||
local now days_remaining expiry_display
|
||||
now=$(date +%s)
|
||||
days_remaining=$(( (expiry_epoch - now) / 86400 ))
|
||||
expiry_display=$(date -d "$expiry_str" '+%Y-%m-%d' 2>/dev/null)
|
||||
|
||||
if [[ "$days_remaining" -le "$CERT_CRIT_DAYS" ]]; then
|
||||
error "$ICON_CERT $domain — CRITICAL: ${days_remaining} days remaining (expires $expiry_display)"
|
||||
return 2
|
||||
elif [[ "$days_remaining" -le "$CERT_WARN_DAYS" ]]; then
|
||||
warn "$ICON_CERT $domain — WARNING: ${days_remaining} days remaining (expires $expiry_display)"
|
||||
return 1
|
||||
else
|
||||
log "$ICON_CERT $domain — OK: ${days_remaining} days remaining (expires $expiry_display)"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Certificate Monitor ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━ $ICON_CERT Certificate Monitor — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
info "Warn threshold: ${CERT_WARN_DAYS} days"
|
||||
info "Crit threshold: ${CERT_CRIT_DAYS} days"
|
||||
echo ""
|
||||
|
||||
START=$(date +%s)
|
||||
HEALTHY=()
|
||||
WARNING=()
|
||||
CRITICAL=()
|
||||
FAILED=()
|
||||
declare -A DOMAIN_STATUS
|
||||
|
||||
for domain in "${CERT_MONITOR_DOMAINS[@]}"; do
|
||||
[[ -z "$domain" ]] && continue
|
||||
check_cert "$domain"
|
||||
result=$?
|
||||
case $result in
|
||||
0) HEALTHY+=("$domain"); DOMAIN_STATUS["$domain"]="OK" ;;
|
||||
1) WARNING+=("$domain"); DOMAIN_STATUS["$domain"]="WARN" ;;
|
||||
2) CRITICAL+=("$domain"); DOMAIN_STATUS["$domain"]="CRIT" ;;
|
||||
3) FAILED+=("$domain"); DOMAIN_STATUS["$domain"]="FAIL" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
END=$(date +%s)
|
||||
|
||||
# ── Send notifications — batched per severity ─────────────────────────────────────────────────
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
[[ ${#CRITICAL[@]} -gt 0 ]] && \
|
||||
notify "Certificate CRITICAL on $(hostname) — expiring within ${CERT_CRIT_DAYS} days: ${CRITICAL[*]}" \
|
||||
"Certificate Monitor" "warning"
|
||||
[[ ${#WARNING[@]} -gt 0 ]] && \
|
||||
notify "Certificate WARNING on $(hostname) — expiring within ${CERT_WARN_DAYS} days: ${WARNING[*]}" \
|
||||
"Certificate Monitor" "warning"
|
||||
[[ ${#FAILED[@]} -gt 0 ]] && \
|
||||
notify "Certificate check FAILED on $(hostname) — could not reach: ${FAILED[*]}" \
|
||||
"Certificate Monitor" "warning"
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Summary ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY CERTIFICATE MONITOR SUMMARY ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
echo " $ICON_SUCCESS Healthy: ${#HEALTHY[@]}"
|
||||
[[ ${#WARNING[@]} -gt 0 ]] && warn "Warning: ${#WARNING[@]} — renewal recommended"
|
||||
[[ ${#CRITICAL[@]} -gt 0 ]] && echo "$ICON_ERROR Critical: ${#CRITICAL[@]} — ACTION REQUIRED"
|
||||
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${#FAILED[@]} — unreachable"
|
||||
echo ""
|
||||
|
||||
# Per-domain results — only show problems, healthy ones stay in log()
|
||||
for domain in "${CERT_MONITOR_DOMAINS[@]}"; do
|
||||
[[ -z "$domain" ]] && continue
|
||||
case "${DOMAIN_STATUS[$domain]:-UNKN}" in
|
||||
OK) log " $ICON_SUCCESS $domain — healthy" ;;
|
||||
WARN) warn " $ICON_WARN $domain — warning" ;;
|
||||
CRIT) echo " $ICON_ERROR $domain — CRITICAL" ;;
|
||||
FAIL) echo " $ICON_ERROR $domain — unreachable" ;;
|
||||
esac
|
||||
done
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — no notifications sent"
|
||||
elif [[ ${#CRITICAL[@]} -gt 0 || ${#FAILED[@]} -gt 0 ]]; then
|
||||
echo "$ICON_ERROR Status: ACTION REQUIRED"
|
||||
elif [[ ${#WARNING[@]} -gt 0 ]]; then
|
||||
warn "Status: WARNINGS — renewal recommended"
|
||||
else
|
||||
echo "$ICON_DONE Status: all ${#HEALTHY[@]} certs healthy ✅"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
[[ ${#CRITICAL[@]} -gt 0 || ${#FAILED[@]} -gt 0 ]] && exit 1
|
||||
exit 0
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Certificate Monitor ============================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# SSL certificate expiry monitoring for all configured domains. Scheduled weekly
|
||||
# (Sunday 9am). Connects via openssl directly to each domain — not to NPM's API,
|
||||
# not to any internal check, but to the actual TLS handshake the outside world sees.
|
||||
#
|
||||
# Per domain: HEALTHY (> CERT_WARN_DAYS remaining, silent) | WARNING (≤ CERT_WARN_DAYS)
|
||||
# | CRITICAL (≤ CERT_CRIT_DAYS) | FAILED (could not connect or parse cert).
|
||||
# Notifications batched by severity — one message lists all WARNING domains, a
|
||||
# separate message lists all CRITICAL domains. Not one notification per domain.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# DESIGN PRINCIPLES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Direct openssl, Not an API
|
||||
# API-based cert checks ask the certificate manager whether the cert is valid.
|
||||
# openssl checks ask the server what cert it is actually serving. These are not
|
||||
# the same question and the answers can differ. Catches: cert renewed in NPM but
|
||||
# server not reloaded (old cert still serving), wrong cert being served to external
|
||||
# clients, chain issues visible externally but not internally, NPM reporting healthy
|
||||
# while the outside world sees an expired cert.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Single Instance Lock
|
||||
# acquire_lock prevents concurrent runs producing duplicate notifications.
|
||||
#
|
||||
# Per-Host Domain List
|
||||
# detect_hosts() aliases HOST*_CERT_MONITOR_DOMAINS → CERT_MONITOR_DOMAINS.
|
||||
# Each server monitors its own domains only.
|
||||
#
|
||||
# Empty Array Guard
|
||||
# Warns and exits cleanly if CERT_MONITOR_DOMAINS is empty — no silent no-op.
|
||||
#
|
||||
# Connection Timeout
|
||||
# CERT_TIMEOUT caps each openssl connection attempt. One unreachable domain
|
||||
# does not block the remaining domains.
|
||||
#
|
||||
# Notification Validated
|
||||
# platform_require_cmd confirms openssl and notify script are present before use.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION
|
||||
# ==============================================================================================
|
||||
#
|
||||
# host*.conf
|
||||
#
|
||||
# HOST*_CERT_MONITOR_DOMAINS
|
||||
# Domains this host monitors. Each domain and subdomain is a separate entry —
|
||||
# they have independent certs. Aliased by detect_hosts() → CERT_MONITOR_DOMAINS.
|
||||
#
|
||||
# master.conf
|
||||
#
|
||||
# CERT_WARN_DAYS
|
||||
# Days before expiry at which to send a warning notification. (default: 30)
|
||||
#
|
||||
# CERT_CRIT_DAYS
|
||||
# Days before expiry at which to send a critical notification. (default: 7)
|
||||
#
|
||||
# CERT_TIMEOUT
|
||||
# Seconds to wait per domain before declaring FAILED. (default: 10)
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# cert_monitor.sh
|
||||
# Check all configured domains and notify on WARNING, CRITICAL, or FAILED.
|
||||
# Silent when all domains are healthy.
|
||||
#
|
||||
# cert_monitor.sh --dry-run
|
||||
# Check all domains and show results. No notifications sent regardless of result.
|
||||
#
|
||||
# cert_monitor.sh --status
|
||||
# Show domain list, warning thresholds, and timeout. Then exit.
|
||||
#
|
||||
# cert_monitor.sh --log
|
||||
# Verbose per-domain output during the run.
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Setup ━━━"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
error "Must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate openssl — required for all cert checks
|
||||
platform_require_cmd \
|
||||
"$(command -v openssl 2>/dev/null || echo /usr/bin/openssl)" \
|
||||
"version" "OpenSSL" \
|
||||
"openssl" || { error "openssl not found — required for certificate checks"; exit 1; }
|
||||
|
||||
platform_require_cmd \
|
||||
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
|
||||
"" "" \
|
||||
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
|
||||
|
||||
acquire_lock
|
||||
|
||||
# detect_hosts() sets MY_ID and aliases HOST*_CERT_MONITOR_DOMAINS
|
||||
detect_hosts
|
||||
|
||||
# Empty array guard
|
||||
if [[ ${#CERT_MONITOR_DOMAINS[@]} -eq 0 ]]; then
|
||||
warn "CERT_MONITOR_DOMAINS is empty for $MY_ID"
|
||||
warn "Check HOST*_CERT_MONITOR_DOMAINS in host*.conf"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Domains to check: ${#CERT_MONITOR_DOMAINS[@]}"
|
||||
log "$ICON_GEAR Config: warn=${CERT_WARN_DAYS}d crit=${CERT_CRIT_DAYS}d timeout=${CERT_TIMEOUT}s"
|
||||
log "$ICON_GEAR Domains: ${CERT_MONITOR_DOMAINS[*]}"
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — results shown but no notifications sent"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Status ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$SHOW_STATUS" == true ]]; then
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_CERT Domains: ${CERT_MONITOR_DOMAINS[*]}"
|
||||
echo "$ICON_WARN Warn at: ${CERT_WARN_DAYS} days remaining"
|
||||
echo "$ICON_ERROR Crit at: ${CERT_CRIT_DAYS} days remaining"
|
||||
echo "$ICON_TIME Timeout: ${CERT_TIMEOUT}s per domain"
|
||||
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ── CERT CHECK FUNCTION ───────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Connects to domain:443 via openssl, extracts expiry date, calculates days remaining.
|
||||
# Returns:
|
||||
# 0 = healthy (> CERT_WARN_DAYS)
|
||||
# 1 = warning (<= CERT_WARN_DAYS)
|
||||
# 2 = critical (<= CERT_CRIT_DAYS)
|
||||
# 3 = failed (could not connect or parse)
|
||||
|
||||
check_cert() {
|
||||
local domain="$1"
|
||||
local port="${2:-443}"
|
||||
_CERT_DAYS=""
|
||||
_CERT_EXPIRY=""
|
||||
|
||||
local expiry_str
|
||||
expiry_str=$(echo | timeout "$CERT_TIMEOUT" openssl s_client \
|
||||
-connect "${domain}:${port}" \
|
||||
-servername "$domain" \
|
||||
2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
|
||||
if [[ -z "$expiry_str" ]]; then
|
||||
error "$ICON_CERT $domain — could not retrieve certificate (unreachable or no TLS)"
|
||||
return 3
|
||||
fi
|
||||
|
||||
local expiry_epoch
|
||||
expiry_epoch=$(date -d "$expiry_str" +%s 2>/dev/null)
|
||||
|
||||
if [[ -z "$expiry_epoch" ]]; then
|
||||
error "$ICON_CERT $domain — could not parse expiry date: $expiry_str"
|
||||
return 3
|
||||
fi
|
||||
|
||||
local now days_remaining expiry_display
|
||||
now=$(date +%s)
|
||||
days_remaining=$(( (expiry_epoch - now) / 86400 ))
|
||||
expiry_display=$(date -d "$expiry_str" '+%Y-%m-%d' 2>/dev/null)
|
||||
_CERT_DAYS=$days_remaining
|
||||
_CERT_EXPIRY=$expiry_display
|
||||
|
||||
if [[ "$days_remaining" -le "$CERT_CRIT_DAYS" ]]; then
|
||||
error "$ICON_CERT $domain — CRITICAL: ${days_remaining} days remaining (expires $expiry_display)"
|
||||
return 2
|
||||
elif [[ "$days_remaining" -le "$CERT_WARN_DAYS" ]]; then
|
||||
warn "$ICON_CERT $domain — WARNING: ${days_remaining} days remaining (expires $expiry_display)"
|
||||
return 1
|
||||
else
|
||||
log "$ICON_CERT $domain — OK: ${days_remaining} days remaining (expires $expiry_display)"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Certificate Monitor ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━ $ICON_CERT Certificate Monitor — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
info "Warn threshold: ${CERT_WARN_DAYS} days"
|
||||
info "Crit threshold: ${CERT_CRIT_DAYS} days"
|
||||
echo ""
|
||||
|
||||
START=$(date +%s)
|
||||
HEALTHY=()
|
||||
WARNING=()
|
||||
CRITICAL=()
|
||||
FAILED=()
|
||||
declare -A DOMAIN_STATUS DOMAIN_DAYS DOMAIN_EXPIRY
|
||||
|
||||
for domain in "${CERT_MONITOR_DOMAINS[@]}"; do
|
||||
[[ -z "$domain" ]] && continue
|
||||
check_cert "$domain"
|
||||
result=$?
|
||||
DOMAIN_DAYS["$domain"]="${_CERT_DAYS:-}"
|
||||
DOMAIN_EXPIRY["$domain"]="${_CERT_EXPIRY:-}"
|
||||
case $result in
|
||||
0) HEALTHY+=("$domain"); DOMAIN_STATUS["$domain"]="OK" ;;
|
||||
1) WARNING+=("$domain"); DOMAIN_STATUS["$domain"]="WARN" ;;
|
||||
2) CRITICAL+=("$domain"); DOMAIN_STATUS["$domain"]="CRIT" ;;
|
||||
3) FAILED+=("$domain"); DOMAIN_STATUS["$domain"]="FAIL" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
END=$(date +%s)
|
||||
|
||||
# ── Send notifications — batched per severity ─────────────────────────────────────────────────
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
[[ ${#CRITICAL[@]} -gt 0 ]] && \
|
||||
notify "Certificate CRITICAL on $(hostname) — expiring within ${CERT_CRIT_DAYS} days: ${CRITICAL[*]}" \
|
||||
"Certificate Monitor" "warning"
|
||||
[[ ${#WARNING[@]} -gt 0 ]] && \
|
||||
notify "Certificate WARNING on $(hostname) — expiring within ${CERT_WARN_DAYS} days: ${WARNING[*]}" \
|
||||
"Certificate Monitor" "warning"
|
||||
[[ ${#FAILED[@]} -gt 0 ]] && \
|
||||
notify "Certificate check FAILED on $(hostname) — could not reach: ${FAILED[*]}" \
|
||||
"Certificate Monitor" "warning"
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Summary ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY CERTIFICATE MONITOR SUMMARY ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
|
||||
echo ""
|
||||
echo " $ICON_SUCCESS Healthy: ${#HEALTHY[@]}"
|
||||
[[ ${#WARNING[@]} -gt 0 ]] && warn "Warning: ${#WARNING[@]} — renewal recommended"
|
||||
[[ ${#CRITICAL[@]} -gt 0 ]] && echo "$ICON_ERROR Critical: ${#CRITICAL[@]} — ACTION REQUIRED"
|
||||
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${#FAILED[@]} — unreachable"
|
||||
echo ""
|
||||
|
||||
# Per-domain results — only show problems, healthy ones stay in log()
|
||||
for domain in "${CERT_MONITOR_DOMAINS[@]}"; do
|
||||
[[ -z "$domain" ]] && continue
|
||||
case "${DOMAIN_STATUS[$domain]:-UNKN}" in
|
||||
OK) log " $ICON_SUCCESS $domain — healthy" ;;
|
||||
WARN) warn " $ICON_WARN $domain — warning" ;;
|
||||
CRIT) echo " $ICON_ERROR $domain — CRITICAL" ;;
|
||||
FAIL) echo " $ICON_ERROR $domain — unreachable" ;;
|
||||
esac
|
||||
done
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — no notifications sent"
|
||||
elif [[ ${#CRITICAL[@]} -gt 0 || ${#FAILED[@]} -gt 0 ]]; then
|
||||
echo "$ICON_ERROR Status: ACTION REQUIRED"
|
||||
elif [[ ${#WARNING[@]} -gt 0 ]]; then
|
||||
warn "Status: WARNINGS — renewal recommended"
|
||||
else
|
||||
echo "$ICON_DONE Status: all ${#HEALTHY[@]} certs healthy ✅"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# ── Write JSON status cache ───────────────────────────────────────────────────
|
||||
_CERT_CACHE_FILE="$SCRIPTS_DIR/State_Files/cert_status.json"
|
||||
{
|
||||
printf '{"checked_at":%d,"host":"%s","warn_days":%d,"crit_days":%d,"dry_run":%s,"domains":[\n' \
|
||||
"$(date +%s)" "$MY_ID" "$CERT_WARN_DAYS" "$CERT_CRIT_DAYS" \
|
||||
"$([[ $DRY_RUN == true ]] && echo true || echo false)"
|
||||
_first=true
|
||||
for _d in "${CERT_MONITOR_DOMAINS[@]}"; do
|
||||
[[ -z "$_d" ]] && continue
|
||||
[[ "$_first" != true ]] && printf ','
|
||||
_first=false
|
||||
_days="${DOMAIN_DAYS[$_d]:-null}"
|
||||
_exp="${DOMAIN_EXPIRY[$_d]:-}"
|
||||
printf '{"domain":"%s","status":"%s","days":%s,"expires":"%s"}\n' \
|
||||
"$_d" "${DOMAIN_STATUS[$_d]:-UNKN}" "$_days" "$_exp"
|
||||
done
|
||||
printf ']}\n'
|
||||
} > "$_CERT_CACHE_FILE" 2>/dev/null
|
||||
|
||||
[[ ${#CRITICAL[@]} -gt 0 || ${#FAILED[@]} -gt 0 ]] && exit 1
|
||||
exit 0
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ================================= CONFIGURATION LOADER =======================================
|
||||
# ==============================================================================================
|
||||
# Single entry point for all configuration sourcing across the ecosystem.
|
||||
# Every script sources this file instead of sourcing master.conf files directly.
|
||||
#
|
||||
# ── HOW IT WORKS ──────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Detects OS platform → PLATFORM=unraid|truenas|unknown; exports SCRIPTS_DIR
|
||||
# 2. Sources master.conf (shared config — hostnames, thresholds, toggles, profiles, job lists)
|
||||
# 3. Auto-discovers and sources all host*.conf files in the same directory
|
||||
# Each host conf extends the shared profile arrays and adds host-specific credentials
|
||||
# 4. Sources common.sh (shared functions — detect_hosts, logging, notifications etc.)
|
||||
# 5. Sources Plugin/<platform>/adapter.sh (platform_*() functions for OS-specific ops)
|
||||
#
|
||||
# ── WHY THIS EXISTS ───────────────────────────────────────────────────────────────────────────
|
||||
# Without this loader every script had to explicitly source each conf file:
|
||||
# source master.conf
|
||||
# source host1.conf
|
||||
# source host2.conf
|
||||
# source common.sh
|
||||
#
|
||||
# Adding a new server meant updating every script.
|
||||
# With this loader — add host3.conf to the git repo and every server
|
||||
# auto-discovers it on next git pull. Zero script changes required. Ever.
|
||||
#
|
||||
# ── ADDING A NEW SERVER ───────────────────────────────────────────────────────────────────────
|
||||
# 1. Create Configurations/host3.conf following the same structure as HOST1/HOST2
|
||||
# 2. Commit and push to git repo
|
||||
# 3. All servers pull it automatically — no other changes needed
|
||||
#
|
||||
# ── USAGE IN SCRIPTS ──────────────────────────────────────────────────────────────────────────
|
||||
# Replace the three source lines at the top of every script with:
|
||||
#
|
||||
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# source "$SCRIPT_DIR/../load_config.sh"
|
||||
#
|
||||
# Scripts in subdirectories (Rsync/, Docker_Essentials/ etc.) use ../ to reach root.
|
||||
# Scripts in root directory use ./ instead:
|
||||
#
|
||||
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# source "$SCRIPT_DIR/load_config.sh"
|
||||
#
|
||||
# ── SPARSE CHECKOUT NOTE ──────────────────────────────────────────────────────────────────────
|
||||
# Sparse checkout controls which host*.conf files each server receives.
|
||||
# HOST1 only pulls Configurations/host1.conf — never HOST2's credentials.
|
||||
# HOST2 only pulls Configurations/host2.conf — never HOST1's credentials.
|
||||
# This loader sources whatever conf files ARE present — sparse checkout handles the rest.
|
||||
# Both servers pull all non-credential conf files (Configurations/master.conf, common.sh, load_config.sh).
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Locate config root ━━━
|
||||
# load_config.sh always lives in the repo root.
|
||||
# Scripts call it from subdirectories using ../ — resolve to the actual root.
|
||||
LOAD_CONFIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ━━━ Platform detection ━━━
|
||||
# PLATFORM drives the adapter layer — Plugin/<platform>/adapter.sh
|
||||
# Each platform adapter provides the same function API; scripts stay OS-agnostic.
|
||||
if [[ -f /etc/unraid-version ]]; then PLATFORM="unraid"
|
||||
elif [[ -f /etc/truenas ]]; then PLATFORM="truenas"
|
||||
else PLATFORM="unknown"
|
||||
fi
|
||||
export PLATFORM SCRIPTS_DIR="$LOAD_CONFIG_DIR"
|
||||
|
||||
# ━━━ Source shared config ━━━
|
||||
# master.conf must be sourced first — it declares the shared PROFILE_* arrays
|
||||
# that Host confs extend. Sourcing host confs before master.conf would fail.
|
||||
if [[ ! -f "$LOAD_CONFIG_DIR/Configurations/master.conf" ]]; then
|
||||
echo "[FATAL] master.conf not found at $LOAD_CONFIG_DIR/Configurations/master.conf" >&2
|
||||
echo "[FATAL] Check TARGET_DIR and git pull status" >&2
|
||||
exit 1
|
||||
fi
|
||||
source "$LOAD_CONFIG_DIR/Configurations/master.conf"
|
||||
|
||||
# ━━━ Auto-discover and source all host*.conf files ━━━
|
||||
# Sorted for consistent load order — HOST1 before HOST2 before HOST3 etc.
|
||||
# Each host conf extends the shared PROFILE_* arrays and adds host-specific vars.
|
||||
# Missing files are silently skipped — sparse checkout intentionally withholds some.
|
||||
# At least one host conf must be present or the ecosystem has no identity to work with.
|
||||
_host_confs_loaded=0
|
||||
declare -A _disk_conf_basenames=()
|
||||
|
||||
# Use a sorted array glob — avoids word-splitting on paths with spaces
|
||||
while IFS= read -r _conf; do
|
||||
[[ -f "$_conf" ]] || continue
|
||||
source "$_conf"
|
||||
(( _host_confs_loaded++ ))
|
||||
_disk_conf_basenames["$(basename "$_conf")"]=1
|
||||
[[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
|
||||
echo "[LOG] Loaded host config: $(basename "$_conf")" >&2
|
||||
done < <(printf '%s\n' "$LOAD_CONFIG_DIR/Configurations"/host*.conf 2>/dev/null | sort)
|
||||
|
||||
if [[ "$_host_confs_loaded" -eq 0 ]]; then
|
||||
echo "[FATAL] No host*.conf files found in $LOAD_CONFIG_DIR" >&2
|
||||
echo "[FATAL] At least one host conf required — check git pull and sparse checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ━━━ Source partner confs from /tmp cache ━━━
|
||||
# conf_sync.sh pulls partner host*.conf files into /tmp/.vv/config/cached/.confs/
|
||||
# on array start and after any conf save. Sourcing them here makes partner vars
|
||||
# (HOST2_*, HOST3_*, …) available without committing credentials to the git repo
|
||||
# or violating sparse checkout — partner confs live in RAM only, cleared on reboot.
|
||||
# Confs already loaded from disk are skipped — disk copy is authoritative.
|
||||
_VV_CONF_CACHE="/tmp/.vv/config/cached/.confs"
|
||||
if [[ -d "$_VV_CONF_CACHE" ]]; then
|
||||
while IFS= read -r _conf; do
|
||||
[[ -f "$_conf" ]] || continue
|
||||
_conf_base="$(basename "$_conf")"
|
||||
# Skip if already sourced from disk
|
||||
[[ -n "${_disk_conf_basenames[$_conf_base]:-}" ]] && continue
|
||||
source "$_conf"
|
||||
[[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
|
||||
echo "[LOG] Loaded cached partner config: $_conf_base" >&2
|
||||
done < <(printf '%s\n' "$_VV_CONF_CACHE"/host*.conf 2>/dev/null | sort)
|
||||
fi
|
||||
unset _VV_CONF_CACHE _conf_base _disk_conf_basenames
|
||||
|
||||
# ━━━ Source shared functions ━━━
|
||||
# common.sh sourced last — it calls detect_hosts() which needs HOST* vars to be set.
|
||||
if [[ ! -f "$LOAD_CONFIG_DIR/common.sh" ]]; then
|
||||
echo "[FATAL] common.sh not found at $LOAD_CONFIG_DIR/common.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
source "$LOAD_CONFIG_DIR/common.sh"
|
||||
|
||||
# ━━━ Source platform adapter ━━━
|
||||
# Provides platform_*() functions used by common.sh and scripts.
|
||||
# Guard lets the ecosystem run before Plugin/<platform>/adapter.sh exists.
|
||||
_adapter="$LOAD_CONFIG_DIR/Plugin/$PLATFORM/adapter.sh"
|
||||
[[ -f "$_adapter" ]] && source "$_adapter"
|
||||
|
||||
# ━━━ Cleanup ━━━
|
||||
unset _conf _host_confs_loaded _adapter LOAD_CONFIG_DIR
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ================================= CONFIGURATION LOADER =======================================
|
||||
# ==============================================================================================
|
||||
# Single entry point for all configuration sourcing across the ecosystem.
|
||||
# Every script sources this file instead of sourcing master.conf files directly.
|
||||
#
|
||||
# ── HOW IT WORKS ──────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Detects OS platform → PLATFORM=unraid|truenas|unknown; exports SCRIPTS_DIR
|
||||
# 2. Sources master.conf (shared config — hostnames, thresholds, toggles, profiles, job lists)
|
||||
# 3. Auto-discovers and sources all host*.conf files in the same directory
|
||||
# Each host conf extends the shared profile arrays and adds host-specific credentials
|
||||
# 4. Sources common.sh (shared functions — detect_hosts, logging, notifications etc.)
|
||||
# 5. Sources Plugin/<platform>/adapter.sh (platform_*() functions for OS-specific ops)
|
||||
#
|
||||
# ── WHY THIS EXISTS ───────────────────────────────────────────────────────────────────────────
|
||||
# Without this loader every script had to explicitly source each conf file:
|
||||
# source master.conf
|
||||
# source host1.conf
|
||||
# source host2.conf
|
||||
# source common.sh
|
||||
#
|
||||
# Adding a new server meant updating every script.
|
||||
# With this loader — add host3.conf to the git repo and every server
|
||||
# auto-discovers it on next git pull. Zero script changes required. Ever.
|
||||
#
|
||||
# ── ADDING A NEW SERVER ───────────────────────────────────────────────────────────────────────
|
||||
# 1. Create Configurations/host3.conf following the same structure as HOST1/HOST2
|
||||
# 2. Commit and push to git repo
|
||||
# 3. All servers pull it automatically — no other changes needed
|
||||
#
|
||||
# ── USAGE IN SCRIPTS ──────────────────────────────────────────────────────────────────────────
|
||||
# Replace the three source lines at the top of every script with:
|
||||
#
|
||||
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# source "$SCRIPT_DIR/../load_config.sh"
|
||||
#
|
||||
# Scripts in subdirectories (Rsync/, Docker_Essentials/ etc.) use ../ to reach root.
|
||||
# Scripts in root directory use ./ instead:
|
||||
#
|
||||
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# source "$SCRIPT_DIR/load_config.sh"
|
||||
#
|
||||
# ── SPARSE CHECKOUT NOTE ──────────────────────────────────────────────────────────────────────
|
||||
# Sparse checkout controls which host*.conf files each server receives.
|
||||
# HOST1 only pulls Configurations/host1.conf — never HOST2's credentials.
|
||||
# HOST2 only pulls Configurations/host2.conf — never HOST1's credentials.
|
||||
# This loader sources whatever conf files ARE present — sparse checkout handles the rest.
|
||||
# Both servers pull all non-credential conf files (Configurations/master.conf, common.sh, load_config.sh).
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Locate config root ━━━
|
||||
# load_config.sh always lives in the repo root.
|
||||
# Scripts call it from subdirectories using ../ — resolve to the actual root.
|
||||
LOAD_CONFIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ━━━ Platform detection ━━━
|
||||
# PLATFORM drives the adapter layer — Plugin/<platform>/adapter.sh
|
||||
# Each platform adapter provides the same function API; scripts stay OS-agnostic.
|
||||
if [[ -f /etc/unraid-version ]]; then PLATFORM="unraid"
|
||||
elif [[ -f /etc/truenas ]]; then PLATFORM="truenas"
|
||||
else PLATFORM="unknown"
|
||||
fi
|
||||
export PLATFORM SCRIPTS_DIR="$LOAD_CONFIG_DIR"
|
||||
|
||||
# ━━━ Source shared config ━━━
|
||||
# master.conf must be sourced first — it declares the shared PROFILE_* arrays
|
||||
# that Host confs extend. Sourcing host confs before master.conf would fail.
|
||||
if [[ ! -f "$LOAD_CONFIG_DIR/Configurations/master.conf" ]]; then
|
||||
echo "[FATAL] master.conf not found at $LOAD_CONFIG_DIR/Configurations/master.conf" >&2
|
||||
echo "[FATAL] Check TARGET_DIR and git pull status" >&2
|
||||
exit 1
|
||||
fi
|
||||
source "$LOAD_CONFIG_DIR/Configurations/master.conf"
|
||||
|
||||
# ━━━ Auto-discover and source all host*.conf files ━━━
|
||||
# Sorted for consistent load order — HOST1 before HOST2 before HOST3 etc.
|
||||
# Each host conf extends the shared PROFILE_* arrays and adds host-specific vars.
|
||||
# Missing files are silently skipped — sparse checkout intentionally withholds some.
|
||||
# At least one host conf must be present or the ecosystem has no identity to work with.
|
||||
_host_confs_loaded=0
|
||||
declare -A _disk_conf_basenames=()
|
||||
|
||||
# Use a sorted array glob — avoids word-splitting on paths with spaces
|
||||
while IFS= read -r _conf; do
|
||||
[[ -f "$_conf" ]] || continue
|
||||
source "$_conf"
|
||||
(( _host_confs_loaded++ ))
|
||||
_disk_conf_basenames["$(basename "$_conf")"]=1
|
||||
[[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
|
||||
echo "[LOG] Loaded host config: $(basename "$_conf")" >&2
|
||||
done < <(printf '%s\n' "$LOAD_CONFIG_DIR/Configurations"/host*.conf 2>/dev/null | sort)
|
||||
|
||||
if [[ "$_host_confs_loaded" -eq 0 ]]; then
|
||||
echo "[FATAL] No host*.conf files found in $LOAD_CONFIG_DIR" >&2
|
||||
echo "[FATAL] At least one host conf required — check git pull and sparse checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ━━━ Source partner confs from /tmp cache ━━━
|
||||
# conf_sync.sh pulls partner host*.conf files into /tmp/.vv/config/cached/.confs/
|
||||
# on array start and after any conf save. Sourcing them here makes partner vars
|
||||
# (HOST2_*, HOST3_*, …) available without committing credentials to the git repo
|
||||
# or violating sparse checkout — partner confs live in RAM only, cleared on reboot.
|
||||
# Confs already loaded from disk are skipped — disk copy is authoritative.
|
||||
_VV_CONF_CACHE="/tmp/.vv/config/cached/.confs"
|
||||
if [[ -d "$_VV_CONF_CACHE" ]]; then
|
||||
while IFS= read -r _conf; do
|
||||
[[ -f "$_conf" ]] || continue
|
||||
_conf_base="$(basename "$_conf")"
|
||||
# Skip if already sourced from disk
|
||||
[[ -n "${_disk_conf_basenames[$_conf_base]:-}" ]] && continue
|
||||
source "$_conf"
|
||||
[[ "${ENABLE_LOGGING:-false}" == "true" ]] && \
|
||||
echo "[LOG] Loaded cached partner config: $_conf_base" >&2
|
||||
done < <(printf '%s\n' "$_VV_CONF_CACHE"/host*.conf 2>/dev/null | sort)
|
||||
fi
|
||||
unset _VV_CONF_CACHE _conf_base _disk_conf_basenames
|
||||
|
||||
# ━━━ Source shared functions ━━━
|
||||
# common.sh sourced last — it calls detect_hosts() which needs HOST* vars to be set.
|
||||
if [[ ! -f "$LOAD_CONFIG_DIR/common.sh" ]]; then
|
||||
echo "[FATAL] common.sh not found at $LOAD_CONFIG_DIR/common.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
source "$LOAD_CONFIG_DIR/common.sh"
|
||||
|
||||
# ━━━ Source platform adapter ━━━
|
||||
# Provides platform_*() functions used by common.sh and scripts.
|
||||
# Guard lets the ecosystem run before Plugin/<platform>/adapter.sh exists.
|
||||
_adapter="$LOAD_CONFIG_DIR/Plugin/$PLATFORM/adapter.sh"
|
||||
[[ -f "$_adapter" ]] && source "$_adapter"
|
||||
|
||||
# ━━━ Derived path constants ━━━
|
||||
# Centralised here so every script that sources load_config.sh has them without
|
||||
# re-deriving from SCRIPTS_DIR or hardcoding /var/log or /tmp paths inline.
|
||||
CONF_DIR="${SCRIPTS_DIR}/Configurations"
|
||||
LOG_DIR="/var/log/varaverk"
|
||||
VV_CACHE_DIR="/tmp/vv_cache"
|
||||
export CONF_DIR LOG_DIR VV_CACHE_DIR
|
||||
|
||||
# ━━━ Cleanup ━━━
|
||||
unset _conf _host_confs_loaded _adapter LOAD_CONFIG_DIR
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+138
@@ -0,0 +1,138 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Conf Cache Sync ================================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Maintains a RAM-resident conf cache at /tmp/.vv/config/cached/.confs/.
|
||||
# Credentials and partner keys live in RAM only — never on disk across hosts.
|
||||
#
|
||||
# On array start (default / --array-start):
|
||||
# 1. Copy own conf to local cache
|
||||
# 2. Pull each available partner's conf from their disk → local cache
|
||||
# 3. Push own conf to each available partner's /tmp/.vv/ cache
|
||||
#
|
||||
# On conf save (--push-only):
|
||||
# Fast path — push updated own conf to all partners' /tmp/.vv/ cache only.
|
||||
# No pulls, no local cache rebuild.
|
||||
#
|
||||
# Cache is /tmp (tmpfs) — cleared every reboot, repopulated by this script
|
||||
# on next array start. Scripts source from cache for partner vars; own vars
|
||||
# always come from disk (load_config.sh skips cached copy of own conf).
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# conf_sync.sh Full sync: pull from all partners + push to all partners
|
||||
# conf_sync.sh --push-only Push own conf to all partners (fast, for conf-save hook)
|
||||
# conf_sync.sh --dry-run Show what would happen, no changes
|
||||
# conf_sync.sh --log Verbose output
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
PUSH_ONLY=false
|
||||
FILTERED_ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--push-only) PUSH_ONLY=true ;;
|
||||
*) FILTERED_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
parse_args "${FILTERED_ARGS[@]}"
|
||||
detect_hosts
|
||||
|
||||
CACHE_DIR="/tmp/.vv/config/cached/.confs"
|
||||
MY_CONF="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
|
||||
SSH_TIMEOUT=10
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
|
||||
# ── Ensure cache dir exists ───────────────────────────────────────────────────
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
mkdir -p "$CACHE_DIR"
|
||||
fi
|
||||
|
||||
# ── Copy own conf into local cache ───────────────────────────────────────────
|
||||
if [[ "$PUSH_ONLY" == false ]]; then
|
||||
if [[ -f "$MY_CONF" ]]; then
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would copy $(basename "$MY_CONF") → $CACHE_DIR/"
|
||||
else
|
||||
cp "$MY_CONF" "$CACHE_DIR/${MY_ID,,}.conf" && \
|
||||
log "Own conf cached ✅" || warn "Failed to cache own conf"
|
||||
fi
|
||||
else
|
||||
warn "Own conf not found: $MY_CONF"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Per-partner sync ──────────────────────────────────────────────────────────
|
||||
PUSHED=0
|
||||
PULLED=0
|
||||
FAILED=0
|
||||
|
||||
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
|
||||
partner_host="${!host_var}"
|
||||
[[ -z "$partner_host" ]] && continue
|
||||
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
|
||||
|
||||
partner_slot="${host_var,,}" # e.g. host2
|
||||
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$partner_ip" ]]; then
|
||||
warn "$partner_host — cannot resolve Tailscale IP, skipping"
|
||||
(( FAILED++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# ── Pull: grab partner's conf from their disk → our local cache ──────────
|
||||
if [[ "$PUSH_ONLY" == false ]]; then
|
||||
remote_conf="/boot/config/plugins/varaverk/Configurations/${partner_slot}.conf"
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would pull $partner_host:$remote_conf → $CACHE_DIR/${partner_slot}.conf"
|
||||
elif timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
|
||||
"root@${partner_ip}:${remote_conf}" \
|
||||
"$CACHE_DIR/${partner_slot}.conf" 2>/dev/null; then
|
||||
log "Pulled ${partner_slot}.conf from $partner_host ✅"
|
||||
(( PULLED++ ))
|
||||
else
|
||||
warn "Could not pull ${partner_slot}.conf from $partner_host"
|
||||
(( FAILED++ ))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Push: send own conf to partner's /tmp/.vv/ cache ────────────────────
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would push ${MY_ID,,}.conf → $partner_host:/tmp/.vv/config/cached/.confs/"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Ensure partner's cache dir exists, then SCP own conf into it
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
|
||||
"root@${partner_ip}" "mkdir -p '$CACHE_DIR'" 2>/dev/null
|
||||
|
||||
if timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
|
||||
"$MY_CONF" \
|
||||
"root@${partner_ip}:${CACHE_DIR}/${MY_ID,,}.conf" 2>/dev/null; then
|
||||
log "Pushed ${MY_ID,,}.conf to $partner_host ✅"
|
||||
(( PUSHED++ ))
|
||||
else
|
||||
warn "Could not push to $partner_host"
|
||||
(( FAILED++ ))
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
if [[ "$PUSH_ONLY" == true ]]; then
|
||||
info "Conf push complete — pushed to $PUSHED host(s)${FAILED:+, $FAILED failed}"
|
||||
else
|
||||
info "Conf sync complete — pulled $PULLED, pushed $PUSHED${FAILED:+, $FAILED failed}"
|
||||
fi
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Conf Cache Sync ================================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Maintains a RAM-resident conf cache at /tmp/.vv/config/cached/.confs/.
|
||||
# Credentials and partner keys live in RAM only — never on disk across hosts.
|
||||
#
|
||||
# On array start (default / --array-start):
|
||||
# 1. Copy own conf to local cache
|
||||
# 2. Pull each available partner's conf from their disk → local cache
|
||||
# 3. Push own conf to each available partner's /tmp/.vv/ cache
|
||||
#
|
||||
# On conf save (--push-only):
|
||||
# Fast path — push updated own conf to all partners' /tmp/.vv/ cache only.
|
||||
# No pulls, no local cache rebuild.
|
||||
#
|
||||
# Cache is /tmp (tmpfs) — cleared every reboot, repopulated by this script
|
||||
# on next array start. Scripts source from cache for partner vars; own vars
|
||||
# always come from disk (load_config.sh skips cached copy of own conf).
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# conf_sync.sh Full sync: pull from all partners + push to all partners
|
||||
# conf_sync.sh --push-only Push own conf to all partners (fast, for conf-save hook)
|
||||
# conf_sync.sh --pull-only Pull partner confs into local cache only (for intermediate orch)
|
||||
# conf_sync.sh --dry-run Show what would happen, no changes
|
||||
# conf_sync.sh --log Verbose output
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
PUSH_ONLY=false
|
||||
PULL_ONLY=false
|
||||
FILTERED_ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--push-only) PUSH_ONLY=true ;;
|
||||
--pull-only) PULL_ONLY=true ;;
|
||||
*) FILTERED_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
parse_args "${FILTERED_ARGS[@]}"
|
||||
detect_hosts
|
||||
|
||||
CACHE_DIR="/tmp/.vv/config/cached/.confs"
|
||||
MY_CONF="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
|
||||
SSH_TIMEOUT=10
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
|
||||
# ── Ensure cache dir exists ───────────────────────────────────────────────────
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
mkdir -p "$CACHE_DIR"
|
||||
fi
|
||||
|
||||
# ── Copy own conf into local cache ───────────────────────────────────────────
|
||||
if [[ "$PUSH_ONLY" == false ]] && [[ "$PULL_ONLY" == false ]]; then
|
||||
if [[ -f "$MY_CONF" ]]; then
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would copy $(basename "$MY_CONF") → $CACHE_DIR/"
|
||||
else
|
||||
cp "$MY_CONF" "$CACHE_DIR/${MY_ID,,}.conf" && \
|
||||
log "Own conf cached ✅" || warn "Failed to cache own conf"
|
||||
fi
|
||||
else
|
||||
warn "Own conf not found: $MY_CONF"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Per-partner sync ──────────────────────────────────────────────────────────
|
||||
PUSHED=0
|
||||
PULLED=0
|
||||
FAILED=0
|
||||
|
||||
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
|
||||
partner_host="${!host_var}"
|
||||
[[ -z "$partner_host" ]] && continue
|
||||
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
|
||||
|
||||
partner_slot="${host_var,,}" # e.g. host2
|
||||
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$partner_ip" ]]; then
|
||||
warn "$partner_host — cannot resolve Tailscale IP, skipping"
|
||||
(( FAILED++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# ── Pull: grab partner's conf from their disk → our local cache ──────────
|
||||
if [[ "$PUSH_ONLY" == false ]]; then
|
||||
remote_conf="/boot/config/plugins/varaverk/Configurations/${partner_slot}.conf"
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would pull $partner_host:$remote_conf → $CACHE_DIR/${partner_slot}.conf"
|
||||
elif timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
|
||||
"root@${partner_ip}:${remote_conf}" \
|
||||
"$CACHE_DIR/${partner_slot}.conf" 2>/dev/null; then
|
||||
log "Pulled ${partner_slot}.conf from $partner_host ✅"
|
||||
(( PULLED++ ))
|
||||
else
|
||||
warn "Could not pull ${partner_slot}.conf from $partner_host"
|
||||
(( FAILED++ ))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Push: send own conf to partner's /tmp/.vv/ cache ────────────────────
|
||||
if [[ "$PULL_ONLY" == true ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would push ${MY_ID,,}.conf → $partner_host:/tmp/.vv/config/cached/.confs/"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Ensure partner's cache dir exists, then SCP own conf into it
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
|
||||
"root@${partner_ip}" "mkdir -p '$CACHE_DIR'" 2>/dev/null
|
||||
|
||||
if timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
|
||||
"$MY_CONF" \
|
||||
"root@${partner_ip}:${CACHE_DIR}/${MY_ID,,}.conf" 2>/dev/null; then
|
||||
log "Pushed ${MY_ID,,}.conf to $partner_host ✅"
|
||||
(( PUSHED++ ))
|
||||
else
|
||||
warn "Could not push to $partner_host"
|
||||
(( FAILED++ ))
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
if [[ "$PUSH_ONLY" == true ]]; then
|
||||
info "Conf push complete — pushed to $PUSHED host(s)${FAILED:+, $FAILED failed}"
|
||||
elif [[ "$PULL_ONLY" == true ]]; then
|
||||
info "Conf pull complete — pulled $PULLED partner conf(s)${FAILED:+, $FAILED failed}"
|
||||
else
|
||||
info "Conf sync complete — pulled $PULLED, pushed $PUSHED${FAILED:+, $FAILED failed}"
|
||||
fi
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ====================== Partnership — Unraid Container Adapter ================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Sourced by partnership_onboard.sh and partnership_offboard.sh via:
|
||||
# source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
|
||||
#
|
||||
# Provides container deploy/cleanup functions specific to the Unraid platform:
|
||||
# - Docker container deployment from Unraid CA XML templates
|
||||
# - Community Applications plugin install via `plugin install`
|
||||
# - FolderView3 docker.json manipulation
|
||||
#
|
||||
# Functions use variables from the calling script's scope (sourced, not exec'd):
|
||||
# MIRROR, MIRROR_IP, MIRROR_SSH_KEY, SSH_TIMEOUT, DRY_RUN, SCRIPTS_ROOT
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
|
||||
|
||||
_STACK_DEPLOYED=0
|
||||
_STACK_FAILED=0
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Install Unraid plugins on the mirror ──────────────────────────────────────────────────────
|
||||
#
|
||||
# Installs FolderView3 via `plugin install` — Unraid Community Applications command.
|
||||
# Checks if already installed before attempting. Returns 1 if install fails.
|
||||
# ==============================================================================================
|
||||
platform_install_plugins() {
|
||||
local remote_ip="$1" ssh_key="$2"
|
||||
|
||||
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] || [[ -z "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
|
||||
log "FolderView3 not configured — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local fv3_present
|
||||
fv3_present=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
|
||||
"test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)
|
||||
|
||||
if [[ "$fv3_present" == "yes" ]]; then
|
||||
log "FolderView3 already installed on $MIRROR ✅"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would install FolderView3 on $MIRROR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Installing FolderView3 on $MIRROR..."
|
||||
if timeout 60 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
||||
"plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \
|
||||
2>/dev/null | grep -q installed; then
|
||||
log "FolderView3 installed ✅"
|
||||
return 0
|
||||
else
|
||||
warn "FolderView3 install failed — install manually from Community Applications"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Wait for a container on the remote to be healthy/running ─────────────────────────────────
|
||||
#
|
||||
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
|
||||
# falls back to the running state. Non-fatal after timeout — some containers take time to
|
||||
# fully initialize but the deploy itself succeeded.
|
||||
# ==============================================================================================
|
||||
wait_for_container_healthy() {
|
||||
local name="$1" remote_ip="$2" ssh_key="$3"
|
||||
local max_wait=60 interval=5 elapsed=0
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && return 0
|
||||
|
||||
log " Waiting for $name to be ready..."
|
||||
while (( elapsed < max_wait )); do
|
||||
local status
|
||||
status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
|
||||
"h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
|
||||
r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
|
||||
echo \${h:-\$r}" 2>/dev/null)
|
||||
|
||||
case "$status" in
|
||||
healthy|true)
|
||||
log " $name ready ✅"
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
sleep "$interval"
|
||||
(( elapsed += interval ))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
warn " $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Deploy a container from a local Unraid CA XML template to a remote host ──────────────────
|
||||
#
|
||||
# Parses Port / Path / Variable Config entries from the Unraid XML, SCPs the template and a
|
||||
# self-contained deploy script to the remote, executes it, then cleans up both sides.
|
||||
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
|
||||
# ==============================================================================================
|
||||
deploy_container_from_xml() {
|
||||
local xml_file="$1" remote_ip="$2" ssh_key="$3"
|
||||
local xml_name
|
||||
xml_name=$(basename "$xml_file")
|
||||
|
||||
local name repo network extra privileged
|
||||
name=$( awk 'match($0,/<Name>([^<]+)<\/Name>/, a){print a[1];exit}' "$xml_file")
|
||||
repo=$( awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
|
||||
network=$( awk 'match($0,/<Network>([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file")
|
||||
extra=$( awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
|
||||
privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")
|
||||
|
||||
if [[ -z "$name" || -z "$repo" ]]; then
|
||||
warn " Cannot parse Name/Repository from $xml_name — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Deploying $name..."
|
||||
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
|
||||
"$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
|
||||
warn " SCP failed for $xml_name — skipping $name"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
warn " DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
|
||||
fi
|
||||
|
||||
local tmp_script
|
||||
tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
|
||||
chmod 600 "$tmp_script"
|
||||
|
||||
{
|
||||
echo "#!/bin/bash"
|
||||
echo "set -e"
|
||||
echo ""
|
||||
printf "docker pull %q 2>/dev/null || true\n" "$repo"
|
||||
printf "docker stop %q 2>/dev/null || true\n" "$name"
|
||||
printf "docker rm %q 2>/dev/null || true\n" "$name"
|
||||
echo ""
|
||||
printf "docker create --name %q --restart=unless-stopped" "$name"
|
||||
[[ -n "$network" ]] && printf " --network=%q" "$network"
|
||||
[[ "$privileged" == "true" ]] && printf " --privileged"
|
||||
[[ -n "$extra" ]] && printf " %s" "$extra"
|
||||
|
||||
# Port mappings → -p host:container/proto
|
||||
awk '/Type="Port"/ {
|
||||
match($0, /Target="([^"]+)"/, t)
|
||||
match($0, /Mode="([^"]+)"/, m)
|
||||
match($0, />([^<]+)<\/Config>/, v)
|
||||
if (t[1] != "" && v[1] != "") {
|
||||
proto = (m[1] == "udp") ? "udp" : "tcp"
|
||||
printf " -p %s:%s/%s", v[1], t[1], proto
|
||||
}
|
||||
}' "$xml_file"
|
||||
|
||||
# Volume mappings → -v 'host:container:mode'
|
||||
awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
|
||||
match($0, /Target="([^"]+)"/, t)
|
||||
match($0, /Mode="([^"]+)"/, m)
|
||||
match($0, />([^<]+)<\/Config>/, v)
|
||||
if (t[1] != "" && v[1] != "") {
|
||||
mode = (m[1] == "ro") ? "ro" : "rw"
|
||||
printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
|
||||
}
|
||||
}' "$xml_file"
|
||||
|
||||
# Environment variables → -e 'KEY=VALUE' (single-quoted to protect $ and special chars)
|
||||
awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
|
||||
match($0, /Target="([^"]+)"/, t)
|
||||
match($0, />([^<]+)<\/Config>/, v)
|
||||
if (t[1] != "" && v[1] != "") {
|
||||
printf " -e %s%s=%s%s", q, t[1], v[1], q
|
||||
}
|
||||
}' "$xml_file"
|
||||
|
||||
printf " %q\n" "$repo"
|
||||
echo ""
|
||||
printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
|
||||
} > "$tmp_script"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would deploy $name on $MIRROR"
|
||||
rm -f "$tmp_script"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"
|
||||
|
||||
if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
|
||||
"$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
|
||||
timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
||||
"bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
|
||||
grep -q "deployed:${name}"; then
|
||||
log " $name deployed ✅"
|
||||
rm -f "$tmp_script"
|
||||
return 0
|
||||
else
|
||||
warn " $name deployment failed — check $MIRROR manually"
|
||||
rm -f "$tmp_script"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Deploy a stack of Unraid CA XMLs to the mirror ───────────────────────────────────────────
|
||||
#
|
||||
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
|
||||
# Health-checks database deps (Mariadb/Redis/Postgres) between batches so dependents
|
||||
# (e.g. Authelia) start cleanly.
|
||||
# ==============================================================================================
|
||||
deploy_xml_stack() {
|
||||
local -n xml_array_ref="$1"
|
||||
_STACK_DEPLOYED=0
|
||||
_STACK_FAILED=0
|
||||
|
||||
for xml_name in "${xml_array_ref[@]}"; do
|
||||
local xml_file="${TEMPLATES_DIR}/${xml_name}"
|
||||
if [[ ! -f "$xml_file" ]]; then
|
||||
warn "$xml_name not found in $TEMPLATES_DIR — skipping"
|
||||
(( _STACK_FAILED++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
local cname
|
||||
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
|
||||
|
||||
if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
|
||||
(( _STACK_DEPLOYED++ ))
|
||||
if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
|
||||
wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
|
||||
fi
|
||||
else
|
||||
(( _STACK_FAILED++ ))
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Remove owner-deployed containers from a remote host ──────────────────────────────────────
|
||||
#
|
||||
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK (owner's conf) to derive container
|
||||
# names from local XML templates. SSHes to remote to stop, remove, and delete appdata.
|
||||
# Appdata paths collected via docker inspect before removal. Safety gate: only
|
||||
# /mnt/*/appdata* paths are deleted.
|
||||
# ==============================================================================================
|
||||
cleanup_deployed_stack_on_remote() {
|
||||
local remote_ip="$1" ssh_key="$2"
|
||||
local -a xml_names=()
|
||||
[[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
|
||||
[[ ${#PARTNERSHIP_ARR_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")
|
||||
|
||||
if [[ ${#xml_names[@]} -eq 0 ]]; then
|
||||
log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
|
||||
for xml_name in "${xml_names[@]}"; do
|
||||
[[ -z "$xml_name" ]] && continue
|
||||
local xml_file="${TEMPLATES_DIR}/${xml_name}"
|
||||
if [[ ! -f "$xml_file" ]]; then
|
||||
warn " $xml_name not found in local $TEMPLATES_DIR — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
local cname
|
||||
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
|
||||
[[ -z "$cname" ]] && continue
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would stop + rm $cname on $MIRROR"
|
||||
warn " DRY RUN — would delete appdata for $cname on $MIRROR"
|
||||
continue
|
||||
fi
|
||||
|
||||
local appdata_paths
|
||||
appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
|
||||
"docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
|
||||
| awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)
|
||||
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
||||
"docker stop '$cname' >/dev/null 2>&1
|
||||
docker rm '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
|
||||
grep -q removed && \
|
||||
log " $cname removed from $MIRROR ✅" || \
|
||||
log " $cname not found on $MIRROR — skipping"
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -z "$path" ]] && continue
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
||||
"rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
|
||||
log " Appdata removed on $MIRROR: $path ✅" || \
|
||||
warn " Failed to remove appdata on $MIRROR: $path"
|
||||
done <<< "$appdata_paths"
|
||||
done
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Remove owner-deployed containers locally (mirror-initiated offboard) ─────────────────────
|
||||
#
|
||||
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
|
||||
# local templates-user/ copies (SCPed there during onboard) to get container names and
|
||||
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
|
||||
# ==============================================================================================
|
||||
cleanup_deployed_stack_locally() {
|
||||
local owner_ip="$1" ssh_key="$2"
|
||||
local -a xml_names=()
|
||||
|
||||
if [[ -n "$owner_ip" ]]; then
|
||||
local -a auth_arr arr_arr
|
||||
mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
|
||||
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
|
||||
detect_hosts 2>/dev/null
|
||||
printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
|
||||
mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
|
||||
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
|
||||
detect_hosts 2>/dev/null
|
||||
printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
|
||||
xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
|
||||
fi
|
||||
|
||||
if [[ ${#xml_names[@]} -eq 0 ]]; then
|
||||
log "Could not read deployed stack from owner — skipping auth/arr cleanup"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Removing owner-deployed containers (auth/arr stacks) locally..."
|
||||
for xml_name in "${xml_names[@]}"; do
|
||||
[[ -z "$xml_name" ]] && continue
|
||||
local xml_file="${TEMPLATES_DIR}/${xml_name}"
|
||||
if [[ ! -f "$xml_file" ]]; then
|
||||
warn " $xml_name not found locally — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
local cname
|
||||
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
|
||||
[[ -z "$cname" ]] && continue
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would stop + rm $cname"
|
||||
warn " DRY RUN — would delete appdata for $cname"
|
||||
continue
|
||||
fi
|
||||
|
||||
local appdata_paths=""
|
||||
if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
|
||||
appdata_paths=$(docker inspect \
|
||||
--format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
|
||||
"$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
|
||||
timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
|
||||
_PM_TRAP_STOPPED+=("$cname")
|
||||
timeout "${DOCKER_TIMEOUT:-30}" docker rm "$cname" >/dev/null 2>&1 && \
|
||||
log " $cname removed ✅" || warn " $cname rm failed"
|
||||
else
|
||||
log " $cname not found locally — skipping"
|
||||
fi
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -z "$path" ]] && continue
|
||||
rm -rf "$path" && log " Appdata removed: $path ✅" || warn " Failed to remove: $path"
|
||||
done <<< "$appdata_paths"
|
||||
done
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Remove a FolderView3 folder from a remote host ───────────────────────────────────────────
|
||||
#
|
||||
# Uses jq on the remote to delete the named folder from docker.json.
|
||||
# No-op if PARTNERSHIP_FOLDERVIEW3 is not enabled or jq is unavailable on remote.
|
||||
# ==============================================================================================
|
||||
platform_remove_folderview3_on_remote() {
|
||||
local remote_ip="$1" ssh_key="$2" folder_name="$3" server_label="${4:-remote}"
|
||||
|
||||
[[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] && return 0
|
||||
|
||||
log "Removing FolderView3 folder '$folder_name' from $server_label..."
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
||||
"fv3='/boot/config/plugins/folder.view3/docker.json'
|
||||
[[ -f \"\$fv3\" ]] && command -v jq >/dev/null 2>&1 && \
|
||||
jq --arg n '$folder_name' \
|
||||
'with_entries(select(.value.name != \$n))' \
|
||||
\"\$fv3\" > \"\${fv3}.tmp\" && \
|
||||
mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \
|
||||
grep -q removed && \
|
||||
log "FolderView3 '$folder_name' removed from $server_label ✅" || \
|
||||
warn "FolderView3 folder not found on $server_label or jq unavailable — skipping"
|
||||
else
|
||||
warn "DRY RUN — would remove FolderView3 folder '$folder_name' from $server_label"
|
||||
fi
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ====================== Partnership — Unraid Container Adapter ================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Sourced by partnership_onboard.sh and partnership_offboard.sh via:
|
||||
# source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
|
||||
#
|
||||
# Provides container deploy/cleanup functions specific to the Unraid platform:
|
||||
# - Docker container deployment from Unraid CA XML templates
|
||||
#
|
||||
# Functions use variables from the calling script's scope (sourced, not exec'd):
|
||||
# MIRROR, MIRROR_IP, MIRROR_SSH_KEY, SSH_TIMEOUT, DRY_RUN, SCRIPTS_ROOT
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
|
||||
|
||||
_STACK_DEPLOYED=0
|
||||
_STACK_FAILED=0
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Wait for a container on the remote to be healthy/running ─────────────────────────────────
|
||||
#
|
||||
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
|
||||
# falls back to the running state. Non-fatal after timeout — some containers take time to
|
||||
# fully initialize but the deploy itself succeeded.
|
||||
# ==============================================================================================
|
||||
wait_for_container_healthy() {
|
||||
local name="$1" remote_ip="$2" ssh_key="$3"
|
||||
local max_wait=60 interval=5 elapsed=0
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && return 0
|
||||
|
||||
log " Waiting for $name to be ready..."
|
||||
while (( elapsed < max_wait )); do
|
||||
local status
|
||||
status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
|
||||
"h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
|
||||
r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
|
||||
echo \${h:-\$r}" 2>/dev/null)
|
||||
|
||||
case "$status" in
|
||||
healthy|true)
|
||||
log " $name ready ✅"
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
sleep "$interval"
|
||||
(( elapsed += interval ))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
warn " $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Deploy a container from a local Unraid CA XML template to a remote host ──────────────────
|
||||
#
|
||||
# Parses Port / Path / Variable Config entries from the Unraid XML, SCPs the template and a
|
||||
# self-contained deploy script to the remote, executes it, then cleans up both sides.
|
||||
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
|
||||
# ==============================================================================================
|
||||
deploy_container_from_xml() {
|
||||
local xml_file="$1" remote_ip="$2" ssh_key="$3"
|
||||
local xml_name
|
||||
xml_name=$(basename "$xml_file")
|
||||
|
||||
local name repo network extra privileged
|
||||
name=$( awk 'match($0,/<Name>([^<]+)<\/Name>/, a){print a[1];exit}' "$xml_file")
|
||||
repo=$( awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
|
||||
network=$( awk 'match($0,/<Network>([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file")
|
||||
extra=$( awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
|
||||
privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")
|
||||
|
||||
if [[ -z "$name" || -z "$repo" ]]; then
|
||||
warn " Cannot parse Name/Repository from $xml_name — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Deploying $name..."
|
||||
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
|
||||
"$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
|
||||
warn " SCP failed for $xml_name — skipping $name"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
warn " DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
|
||||
fi
|
||||
|
||||
local tmp_script
|
||||
tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
|
||||
chmod 600 "$tmp_script"
|
||||
|
||||
{
|
||||
echo "#!/bin/bash"
|
||||
echo "set -e"
|
||||
echo ""
|
||||
printf "docker pull %q 2>/dev/null || true\n" "$repo"
|
||||
printf "docker stop %q 2>/dev/null || true\n" "$name"
|
||||
printf "docker rm %q 2>/dev/null || true\n" "$name"
|
||||
echo ""
|
||||
printf "docker create --name %q --restart=unless-stopped" "$name"
|
||||
[[ -n "$network" ]] && printf " --network=%q" "$network"
|
||||
[[ "$privileged" == "true" ]] && printf " --privileged"
|
||||
[[ -n "$extra" ]] && printf " %s" "$extra"
|
||||
|
||||
# Port mappings → -p host:container/proto
|
||||
awk '/Type="Port"/ {
|
||||
match($0, /Target="([^"]+)"/, t)
|
||||
match($0, /Mode="([^"]+)"/, m)
|
||||
match($0, />([^<]+)<\/Config>/, v)
|
||||
if (t[1] != "" && v[1] != "") {
|
||||
proto = (m[1] == "udp") ? "udp" : "tcp"
|
||||
printf " -p %s:%s/%s", v[1], t[1], proto
|
||||
}
|
||||
}' "$xml_file"
|
||||
|
||||
# Volume mappings → -v 'host:container:mode'
|
||||
awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
|
||||
match($0, /Target="([^"]+)"/, t)
|
||||
match($0, /Mode="([^"]+)"/, m)
|
||||
match($0, />([^<]+)<\/Config>/, v)
|
||||
if (t[1] != "" && v[1] != "") {
|
||||
mode = (m[1] == "ro") ? "ro" : "rw"
|
||||
printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
|
||||
}
|
||||
}' "$xml_file"
|
||||
|
||||
# Environment variables → -e 'KEY=VALUE' (single-quoted to protect $ and special chars)
|
||||
awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
|
||||
match($0, /Target="([^"]+)"/, t)
|
||||
match($0, />([^<]+)<\/Config>/, v)
|
||||
if (t[1] != "" && v[1] != "") {
|
||||
printf " -e %s%s=%s%s", q, t[1], v[1], q
|
||||
}
|
||||
}' "$xml_file"
|
||||
|
||||
printf " %q\n" "$repo"
|
||||
echo ""
|
||||
printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
|
||||
} > "$tmp_script"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would deploy $name on $MIRROR"
|
||||
rm -f "$tmp_script"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"
|
||||
|
||||
if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
|
||||
"$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
|
||||
timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
||||
"bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
|
||||
grep -q "deployed:${name}"; then
|
||||
log " $name deployed ✅"
|
||||
rm -f "$tmp_script"
|
||||
return 0
|
||||
else
|
||||
warn " $name deployment failed — check $MIRROR manually"
|
||||
rm -f "$tmp_script"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Deploy a stack of Unraid CA XMLs to the mirror ───────────────────────────────────────────
|
||||
#
|
||||
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
|
||||
# Health-checks database deps (Mariadb/Redis/Postgres) between batches so dependents
|
||||
# (e.g. Authelia) start cleanly.
|
||||
# ==============================================================================================
|
||||
deploy_xml_stack() {
|
||||
local -n xml_array_ref="$1"
|
||||
_STACK_DEPLOYED=0
|
||||
_STACK_FAILED=0
|
||||
|
||||
for xml_name in "${xml_array_ref[@]}"; do
|
||||
local xml_file="${TEMPLATES_DIR}/${xml_name}"
|
||||
if [[ ! -f "$xml_file" ]]; then
|
||||
warn "$xml_name not found in $TEMPLATES_DIR — skipping"
|
||||
(( _STACK_FAILED++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
local cname
|
||||
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
|
||||
|
||||
if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
|
||||
(( _STACK_DEPLOYED++ ))
|
||||
if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
|
||||
wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
|
||||
fi
|
||||
else
|
||||
(( _STACK_FAILED++ ))
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Remove owner-deployed containers from a remote host ──────────────────────────────────────
|
||||
#
|
||||
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK (owner's conf) to derive container
|
||||
# names from local XML templates. SSHes to remote to stop, remove, and delete appdata.
|
||||
# Appdata paths collected via docker inspect before removal. Safety gate: only
|
||||
# /mnt/*/appdata* paths are deleted.
|
||||
# ==============================================================================================
|
||||
cleanup_deployed_stack_on_remote() {
|
||||
local remote_ip="$1" ssh_key="$2"
|
||||
local -a xml_names=()
|
||||
[[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
|
||||
[[ ${#PARTNERSHIP_ARR_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")
|
||||
|
||||
if [[ ${#xml_names[@]} -eq 0 ]]; then
|
||||
log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
|
||||
for xml_name in "${xml_names[@]}"; do
|
||||
[[ -z "$xml_name" ]] && continue
|
||||
local xml_file="${TEMPLATES_DIR}/${xml_name}"
|
||||
if [[ ! -f "$xml_file" ]]; then
|
||||
warn " $xml_name not found in local $TEMPLATES_DIR — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
local cname
|
||||
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
|
||||
[[ -z "$cname" ]] && continue
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would stop + rm $cname on $MIRROR"
|
||||
warn " DRY RUN — would delete appdata for $cname on $MIRROR"
|
||||
continue
|
||||
fi
|
||||
|
||||
local appdata_paths
|
||||
appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
|
||||
"docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
|
||||
| awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)
|
||||
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
||||
"docker stop '$cname' >/dev/null 2>&1
|
||||
docker rm '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
|
||||
grep -q removed && \
|
||||
log " $cname removed from $MIRROR ✅" || \
|
||||
log " $cname not found on $MIRROR — skipping"
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -z "$path" ]] && continue
|
||||
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
||||
"rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
|
||||
log " Appdata removed on $MIRROR: $path ✅" || \
|
||||
warn " Failed to remove appdata on $MIRROR: $path"
|
||||
done <<< "$appdata_paths"
|
||||
done
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Remove owner-deployed containers locally (mirror-initiated offboard) ─────────────────────
|
||||
#
|
||||
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
|
||||
# local templates-user/ copies (SCPed there during onboard) to get container names and
|
||||
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
|
||||
# ==============================================================================================
|
||||
cleanup_deployed_stack_locally() {
|
||||
local owner_ip="$1" ssh_key="$2"
|
||||
local -a xml_names=()
|
||||
|
||||
if [[ -n "$owner_ip" ]]; then
|
||||
local -a auth_arr arr_arr
|
||||
mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
|
||||
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
|
||||
detect_hosts 2>/dev/null
|
||||
printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
|
||||
mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
||||
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
|
||||
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
|
||||
detect_hosts 2>/dev/null
|
||||
printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
|
||||
xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
|
||||
fi
|
||||
|
||||
if [[ ${#xml_names[@]} -eq 0 ]]; then
|
||||
log "Could not read deployed stack from owner — skipping auth/arr cleanup"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Removing owner-deployed containers (auth/arr stacks) locally..."
|
||||
for xml_name in "${xml_names[@]}"; do
|
||||
[[ -z "$xml_name" ]] && continue
|
||||
local xml_file="${TEMPLATES_DIR}/${xml_name}"
|
||||
if [[ ! -f "$xml_file" ]]; then
|
||||
warn " $xml_name not found locally — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
local cname
|
||||
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
|
||||
[[ -z "$cname" ]] && continue
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would stop + rm $cname"
|
||||
warn " DRY RUN — would delete appdata for $cname"
|
||||
continue
|
||||
fi
|
||||
|
||||
local appdata_paths=""
|
||||
if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
|
||||
appdata_paths=$(docker inspect \
|
||||
--format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
|
||||
"$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
|
||||
timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
|
||||
_PM_TRAP_STOPPED+=("$cname")
|
||||
timeout "${DOCKER_TIMEOUT:-30}" docker rm "$cname" >/dev/null 2>&1 && \
|
||||
log " $cname removed ✅" || warn " $cname rm failed"
|
||||
else
|
||||
log " $cname not found locally — skipping"
|
||||
fi
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -z "$path" ]] && continue
|
||||
rm -rf "$path" && log " Appdata removed: $path ✅" || warn " Failed to remove: $path"
|
||||
done <<< "$appdata_paths"
|
||||
done
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-store, no-cache');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$host = vv_detect_host();
|
||||
if (!preg_match('/^host\d+$/', $host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Cannot detect local host']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hostUpper = strtoupper($host);
|
||||
$varName = $hostUpper . '_UNRAID_API_KEY';
|
||||
$confFile = $host . '.conf';
|
||||
|
||||
// Create/overwrite the Varaverk API key.
|
||||
// --description and --roles are required to suppress interactive prompts.
|
||||
// --overwrite replaces any existing key with the same name (keeps it to one).
|
||||
$dbg = ['ts' => date('H:i:s'), 'user' => trim(shell_exec('whoami'))];
|
||||
$output = shell_exec('timeout 10 /usr/local/sbin/unraid-api apikey --name "Varaverk" --create --overwrite --description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1');
|
||||
$dbg['raw'] = $output;
|
||||
file_put_contents('/tmp/vv_apikey_debug.json', json_encode($dbg, JSON_PRETTY_PRINT));
|
||||
|
||||
if (!$output) {
|
||||
echo json_encode(['ok' => false, 'error' => 'unraid-api returned no output — check /tmp/vv_apikey_debug.json']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(trim($output), true);
|
||||
if (!is_array($data)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Could not parse unraid-api output', 'raw' => substr($output, 0, 300)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$key = $data['key'] ?? null;
|
||||
if (!$key) {
|
||||
echo json_encode(['ok' => false, 'error' => 'No key in response', 'raw' => substr($output, 0, 300)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Read conf, replace the key value, write back
|
||||
$raw = vv_read_conf_raw($confFile);
|
||||
if ($raw === '') {
|
||||
echo json_encode(['ok' => false, 'error' => 'Cannot read ' . $confFile]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// If line is missing (older conf created before this field was added to the template),
|
||||
// insert it after HOST*_OWNER_EMAIL, or after HOST*_SSH_KEY, or append to file.
|
||||
if (!str_contains($raw, $varName)) {
|
||||
$inserted = false;
|
||||
foreach ([$hostUpper . '_OWNER_EMAIL', $hostUpper . '_SSH_KEY'] as $anchor) {
|
||||
if (str_contains($raw, $anchor)) {
|
||||
$raw = preg_replace(
|
||||
'/^(\s*' . preg_quote($anchor, '/') . '\s*=.*$)/m',
|
||||
'$1' . "\n " . $varName . '=""',
|
||||
$raw, 1
|
||||
);
|
||||
$inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$inserted) {
|
||||
$raw = rtrim($raw) . "\n " . $varName . '=""' . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Replace quoted value in-place
|
||||
$updated = preg_replace(
|
||||
'/^(\s*' . preg_quote($varName, '/') . '\s*=\s*)"[^"]*"/m',
|
||||
'${1}"' . $key . '"',
|
||||
$raw
|
||||
);
|
||||
|
||||
if (!vv_write_conf_raw($confFile, $updated)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Failed to write ' . $confFile]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'key_preview' => substr($key, 0, 8) . '...' . substr($key, -4),
|
||||
'conf_file' => $confFile,
|
||||
]);
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-store, no-cache');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$host = vv_detect_host();
|
||||
if (!preg_match('/^host\d+$/', $host)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Cannot detect local host']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(vv_auto_create_api_key($host, $host . '.conf'));
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$scriptsDir = trim($_POST['scripts_dir'] ?? '');
|
||||
|
||||
if (!$scriptsDir) {
|
||||
echo json_encode(['ok' => false, 'error' => 'scripts_dir is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!is_dir($scriptsDir)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Directory does not exist']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cfgFile = '/boot/config/plugins/varaverk/varaverk.cfg';
|
||||
$cfgDir = dirname($cfgFile);
|
||||
if (!is_dir($cfgDir)) mkdir($cfgDir, 0755, true);
|
||||
|
||||
$content = 'SCRIPTS_DIR="' . addslashes($scriptsDir) . '"' . "\n";
|
||||
$ok = file_put_contents($cfgFile, $content) !== false;
|
||||
|
||||
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write cfg file']);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$scriptsDir = trim($_POST['scripts_dir'] ?? '');
|
||||
|
||||
if (!$scriptsDir) {
|
||||
echo json_encode(['ok' => false, 'error' => 'scripts_dir is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!is_dir($scriptsDir)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Directory does not exist']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cfgFile = PLUGIN_CFG;
|
||||
$cfgDir = dirname($cfgFile);
|
||||
if (!is_dir($cfgDir)) mkdir($cfgDir, 0755, true);
|
||||
|
||||
$content = 'SCRIPTS_DIR="' . addslashes($scriptsDir) . '"' . "\n";
|
||||
$ok = file_put_contents($cfgFile, $content) !== false;
|
||||
|
||||
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write cfg file']);
|
||||
@@ -0,0 +1,9 @@
|
||||
// Varaverk — shared JS utilities
|
||||
// Page-specific JS lives inline in each page partial.
|
||||
|
||||
// Flash a status element briefly then fade
|
||||
function vvFlashStatus(el, msg, ok) {
|
||||
el.textContent = msg;
|
||||
el.style.color = ok ? '#4caf50' : '#f44336';
|
||||
setTimeout(() => { el.textContent = ''; }, 3000);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Varaverk — shared JS utilities
|
||||
// Page-specific JS lives inline in each page partial.
|
||||
|
||||
// Flash a status element briefly then fade
|
||||
function vvFlashStatus(el, msg, ok) {
|
||||
el.textContent = msg;
|
||||
el.style.color = ok ? '#4caf50' : '#f44336';
|
||||
setTimeout(() => { el.textContent = ''; }, 3000);
|
||||
}
|
||||
|
||||
// ── Fullscreen toggle — hides Unraid header + menu ────────────────────────────
|
||||
function vvToggleExpand() {
|
||||
const on = document.body.classList.toggle('vv-fullscreen');
|
||||
const btn = document.getElementById('vv-expand-btn');
|
||||
if (btn) { btn.classList.toggle('active', on); btn.title = on ? 'Collapse' : 'Expand'; }
|
||||
localStorage.setItem('vv-fullscreen', on ? '1' : '');
|
||||
}
|
||||
|
||||
// Restore state on every page load
|
||||
(function() {
|
||||
if (localStorage.getItem('vv-fullscreen') !== '1') return;
|
||||
document.body.classList.add('vv-fullscreen');
|
||||
// Button may not exist yet if script runs before DOM — wait for it
|
||||
const apply = () => {
|
||||
const btn = document.getElementById('vv-expand-btn');
|
||||
if (btn) { btn.classList.add('active'); btn.title = 'Collapse'; }
|
||||
};
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply);
|
||||
else apply();
|
||||
})();
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function vv_auth_conf(): array {
|
||||
$v = vv_conf_vars();
|
||||
$host = strtoupper(vv_detect_host());
|
||||
return [
|
||||
'npm_url' => rtrim($v["{$host}_NPM_URL"] ?? 'http://localhost:81', '/'),
|
||||
'npm_user' => $v["{$host}_NPM_USER"] ?? '',
|
||||
'npm_pass' => $v["{$host}_NPM_PASS"] ?? '',
|
||||
'lldap_url' => rtrim($v["{$host}_LLDAP_URL"] ?? 'http://localhost:17170', '/'),
|
||||
'lldap_user' => $v["{$host}_LLDAP_USER"] ?? '',
|
||||
'lldap_pass' => $v["{$host}_LLDAP_PASS"] ?? '',
|
||||
'authelia_config' => $v["{$host}_AUTHELIA_CONFIG"] ?? '/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml',
|
||||
'authelia_container' => $v["{$host}_AUTHELIA_CONTAINER"] ?? 'Authelia',
|
||||
'is_owner' => vv_is_owner(),
|
||||
];
|
||||
}
|
||||
|
||||
// ── NPM ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function vv_npm_token(): string {
|
||||
if (!session_id()) session_start();
|
||||
$conf = vv_auth_conf();
|
||||
$cached = $_SESSION['vv_npm_token'] ?? '';
|
||||
$expiry = $_SESSION['vv_npm_token_exp'] ?? 0;
|
||||
if ($cached && time() < $expiry) return $cached;
|
||||
|
||||
$resp = vv_npm_raw('POST', '/api/tokens', [
|
||||
'identity' => $conf['npm_user'],
|
||||
'secret' => $conf['npm_pass'],
|
||||
'expiry' => '1d',
|
||||
], '', $conf);
|
||||
$token = $resp['token'] ?? '';
|
||||
if ($token) {
|
||||
$_SESSION['vv_npm_token'] = $token;
|
||||
$_SESSION['vv_npm_token_exp'] = time() + 82800;
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
function vv_npm_raw(string $method, string $path, array $data, string $token, array $conf = []): array {
|
||||
if (!$conf) $conf = vv_auth_conf();
|
||||
$url = $conf['npm_url'] . $path;
|
||||
$headers = ['Content-Type: application/json', 'Accept: application/json'];
|
||||
if ($token) $headers[] = 'Authorization: Bearer ' . $token;
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($data && in_array($method, ['POST', 'PUT'], true))
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
$body = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return json_decode($body ?: '{}', true) ?: [];
|
||||
}
|
||||
|
||||
function vv_npm_req(string $method, string $path, array $data = []): array {
|
||||
$token = vv_npm_token();
|
||||
if (!$token) return ['_err' => 'NPM auth failed — check credentials in host conf'];
|
||||
return vv_npm_raw($method, $path, $data, $token);
|
||||
}
|
||||
|
||||
function vv_npm_list_proxies(): array {
|
||||
$list = vv_npm_req('GET', '/api/nginx/proxy-hosts?expand=certificate');
|
||||
if (!is_array($list) || isset($list['_err']))
|
||||
return ['ok' => false, 'error' => $list['_err'] ?? 'Invalid response from NPM'];
|
||||
return ['ok' => true, 'proxies' => $list];
|
||||
}
|
||||
|
||||
function vv_npm_list_certs(): array {
|
||||
$list = vv_npm_req('GET', '/api/nginx/certificates');
|
||||
return is_array($list) ? $list : [];
|
||||
}
|
||||
|
||||
function vv_npm_create_proxy(array $data): array {
|
||||
$r = vv_npm_req('POST', '/api/nginx/proxy-hosts', $data);
|
||||
return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Create failed')];
|
||||
}
|
||||
|
||||
function vv_npm_update_proxy(int $id, array $data): array {
|
||||
$r = vv_npm_req('PUT', "/api/nginx/proxy-hosts/$id", $data);
|
||||
return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Update failed')];
|
||||
}
|
||||
|
||||
function vv_npm_delete_proxy(int $id): array {
|
||||
vv_npm_req('DELETE', "/api/nginx/proxy-hosts/$id");
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function vv_npm_toggle_proxy(int $id, bool $enabled): array {
|
||||
vv_npm_req('POST', "/api/nginx/proxy-hosts/$id/" . ($enabled ? 'enable' : 'disable'));
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// ── lldap ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function vv_lldap_token(): string {
|
||||
if (!session_id()) session_start();
|
||||
$conf = vv_auth_conf();
|
||||
$cached = $_SESSION['vv_lldap_token'] ?? '';
|
||||
$expiry = $_SESSION['vv_lldap_token_exp'] ?? 0;
|
||||
if ($cached && time() < $expiry) return $cached;
|
||||
|
||||
$ch = curl_init($conf['lldap_url'] . '/auth/simple/login');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['username' => $conf['lldap_user'], 'password' => $conf['lldap_pass']]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
$resp = json_decode($body ?: '{}', true) ?: [];
|
||||
$token = $resp['token'] ?? '';
|
||||
if ($token) {
|
||||
$_SESSION['vv_lldap_token'] = $token;
|
||||
$_SESSION['vv_lldap_token_exp'] = time() + 3500;
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
function vv_lldap_gql(string $query, array $variables = []): array {
|
||||
$conf = vv_auth_conf();
|
||||
$token = vv_lldap_token();
|
||||
if (!$token) return ['errors' => [['message' => 'lldap auth failed — check credentials']]];
|
||||
|
||||
$ch = curl_init($conf['lldap_url'] . '/api/graphql');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['query' => $query, 'variables' => $variables]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return json_decode($body ?: '{}', true) ?: [];
|
||||
}
|
||||
|
||||
function vv_lldap_list_users(): array {
|
||||
$r = vv_lldap_gql('query { listUsers { id displayName email creationDate groups { id displayName } } }');
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
|
||||
return ['ok' => true, 'users' => $r['data']['listUsers'] ?? []];
|
||||
}
|
||||
|
||||
function vv_lldap_list_groups(): array {
|
||||
$r = vv_lldap_gql('query { listGroups { id displayName users { id displayName } } }');
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
|
||||
return ['ok' => true, 'groups' => $r['data']['listGroups'] ?? []];
|
||||
}
|
||||
|
||||
function vv_lldap_create_user(string $id, string $email, string $displayName, string $password): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id displayName email } }',
|
||||
['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
|
||||
if ($password) vv_lldap_set_password($id, $password);
|
||||
return ['ok' => true, 'user' => $r['data']['createUser'] ?? []];
|
||||
}
|
||||
|
||||
function vv_lldap_update_user(string $id, string $email, string $displayName): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation UpdateUser($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
|
||||
['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Update failed'];
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function vv_lldap_delete_user(string $id): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation DeleteUser($userId: String!) { deleteUser(userId: $userId) { ok } }',
|
||||
['userId' => $id]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function vv_lldap_set_password(string $userId, string $password): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation ChangePassword($userId: String!, $password: String!) { changeUserPassword(userId: $userId, password: $password) }',
|
||||
['userId' => $userId, 'password' => $password]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Password change failed'];
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function vv_lldap_create_group(string $name): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }',
|
||||
['name' => $name]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
|
||||
return ['ok' => true, 'group' => $r['data']['createGroup'] ?? []];
|
||||
}
|
||||
|
||||
function vv_lldap_delete_group(int $id): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation DeleteGroup($groupId: Int!) { deleteGroup(groupId: $groupId) { ok } }',
|
||||
['groupId' => $id]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function vv_lldap_add_to_group(string $userId, int $groupId): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation AddUserToGroup($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }',
|
||||
['userId' => $userId, 'groupId' => $groupId]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function vv_lldap_remove_from_group(string $userId, int $groupId): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation RemoveUserFromGroup($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }',
|
||||
['userId' => $userId, 'groupId' => $groupId]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// ── Authelia ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function vv_authelia_read_rules(): array {
|
||||
$conf = vv_auth_conf();
|
||||
$file = $conf['authelia_config'];
|
||||
if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];
|
||||
|
||||
$py = "import yaml,json,sys\n"
|
||||
. "d=yaml.safe_load(open(sys.argv[1]))\n"
|
||||
. "ac=d.get('access_control',{})\n"
|
||||
. "print(json.dumps({'default_policy':ac.get('default_policy','deny'),'rules':ac.get('rules',[])}))\n";
|
||||
$tmp = '/tmp/vv_auth_rd_' . getmypid() . '.py';
|
||||
file_put_contents($tmp, $py);
|
||||
$out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' 2>/dev/null');
|
||||
@unlink($tmp);
|
||||
|
||||
if (!$out) return ['ok' => false, 'error' => 'Parse failed — python3 with PyYAML required'];
|
||||
$data = json_decode(trim($out), true);
|
||||
if (!$data) return ['ok' => false, 'error' => 'Invalid YAML response'];
|
||||
return ['ok' => true, 'default_policy' => $data['default_policy'], 'rules' => $data['rules']];
|
||||
}
|
||||
|
||||
function vv_authelia_write_rules(array $rules, string $defaultPolicy): array {
|
||||
$conf = vv_auth_conf();
|
||||
$file = $conf['authelia_config'];
|
||||
if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];
|
||||
|
||||
$acJson = json_encode(['default_policy' => $defaultPolicy, 'rules' => $rules]);
|
||||
$py = <<<'PYEOF'
|
||||
import yaml, json, sys, re
|
||||
config_file = sys.argv[1]
|
||||
new_ac = json.loads(sys.argv[2])
|
||||
with open(config_file, 'r') as f:
|
||||
content = f.read()
|
||||
new_block = yaml.dump({'access_control': new_ac}, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
pattern = r'(?ms)^access_control:.*?(?=^[a-zA-Z#]|\Z)'
|
||||
if re.search(pattern, content):
|
||||
content = re.sub(pattern, new_block + '\n', content)
|
||||
else:
|
||||
content = content.rstrip('\n') + '\n\n' + new_block + '\n'
|
||||
with open(config_file, 'w') as f:
|
||||
f.write(content)
|
||||
print('ok')
|
||||
PYEOF;
|
||||
$tmp = '/tmp/vv_auth_wr_' . getmypid() . '.py';
|
||||
file_put_contents($tmp, $py);
|
||||
$out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' ' . escapeshellarg($acJson) . ' 2>&1');
|
||||
@unlink($tmp);
|
||||
|
||||
if (trim($out) !== 'ok') return ['ok' => false, 'error' => 'Write failed: ' . trim($out)];
|
||||
shell_exec('docker restart ' . escapeshellarg($conf['authelia_container']) . ' >/dev/null 2>&1 &');
|
||||
return ['ok' => true];
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ========================== HOST1 CONFIGURATION — unRAID-Gmer4Lfe ============================
|
||||
# ==============================================================================================
|
||||
# HOST1-specific variables — credentials, container names, share paths, failover lists.
|
||||
# Sourced after master.conf — values here extend shared profile arrays and add HOST1-specific
|
||||
# identity, credentials, and container configuration.
|
||||
#
|
||||
# Sparse checkout (git) ensures HOST2 never receives this file.
|
||||
# HOST2 never sees HOST1 credentials — clean separation at the file level.
|
||||
#
|
||||
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
|
||||
# DO NOT put HOST2 variables here — they belong in host2.conf.
|
||||
#
|
||||
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
|
||||
# IDENTITY hostname, SSH key
|
||||
# EMBY container name, URL, API key
|
||||
# NOTIFICATIONS Discord webhook
|
||||
# PARTNERSHIP auth containers, backup paths
|
||||
#
|
||||
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
|
||||
# DAILY SYNC SHARES media shares HOST1 owns and pushes to HOST2
|
||||
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
|
||||
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
|
||||
# BACKUP VERIFY shares for checksum verification against remote
|
||||
# HOST1 RSYNC PROFILE host1-appdata profile for HOST1-specific appdata syncs
|
||||
#
|
||||
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
|
||||
# DOCKER DAILY RESTART containers restarted daily
|
||||
# DOCKER WEEKLY RESTART containers restarted weekly
|
||||
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
|
||||
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
|
||||
#
|
||||
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
|
||||
# DDNS DDNS containers managed by HOST1
|
||||
# INTERNET LOSS containers stopped when internet is lost
|
||||
# FALLBACK TIERS what HOST1 runs for HOST2 per tier
|
||||
# TIER DELAYS how long HOST1 must be down before each tier activates on HOST2
|
||||
# RSYNC WRITEBACK HOST1 appdata synced back on handback
|
||||
#
|
||||
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
|
||||
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
|
||||
# MEDIA CLEANER folder lists for media_cleaner.sh
|
||||
#
|
||||
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
|
||||
# CERTIFICATE MONITOR domains checked for SSL expiry
|
||||
# SMART HEALTH drives to skip in SMART monitoring
|
||||
# ZFS REPORT pools to exclude from ZFS health report
|
||||
#
|
||||
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
|
||||
# TRANSCODES ramdisk size, thresholds, SSD path, server array
|
||||
#
|
||||
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
|
||||
# DOWNLOADERS slskd, SABnzbd, qBittorrent credentials and URLs
|
||||
# LIDARR URL, API key, path map
|
||||
# SONARR URL, API key, path map
|
||||
# RADARR URL, API key, path map
|
||||
# ARR RECOVERY per-arr recovery toggles
|
||||
#
|
||||
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
|
||||
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
|
||||
#
|
||||
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
|
||||
# RESOURCE MANAGER containers paused/stopped under memory pressure
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
# ==============================================================================================
|
||||
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Identity ━━━
|
||||
# HOST1 hostname lives in master.conf (not a credential — safe for all servers).
|
||||
# SSH key used for all server-to-server operations — rsync, failover container commands.
|
||||
# Must be in /root/.ssh/ and authorised in HOST2's /root/.ssh/authorized_keys.
|
||||
HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation"
|
||||
HOST1_OWNER="gmer4lfe"
|
||||
HOST1_OWNER_EMAIL="gmer4lfe@gmail.com"
|
||||
|
||||
# ━━━ Emby ━━━
|
||||
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
|
||||
# weekly_sync_maintenance.sh, and HOST1_TRANSCODE_SERVERS below.
|
||||
# API key: Emby Dashboard → API Keys → + New Key
|
||||
HOST1_EMBY_CONTAINER="Emby"
|
||||
HOST1_EMBY_URL="http://localhost:8096"
|
||||
HOST1_EMBY_API_KEY="0c27448d93a7431f9ac63569f7655829"
|
||||
|
||||
# ━━━ Jellyfin ━━━
|
||||
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
|
||||
HOST1_JELLYFIN_CONTAINER="Jellyfin"
|
||||
HOST1_JELLYFIN_URL="http://localhost:8095"
|
||||
HOST1_JELLYFIN_API_KEY="4e820e7df74c4933acec212b1996314e"
|
||||
|
||||
# ━━━ Gitea ━━━
|
||||
# Personal access token for gitea_ssh_setup.sh — registers this server's SSH public key
|
||||
# with Gitea so git operations use key auth instead of passwords.
|
||||
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user
|
||||
HOST1_GITEA_API_TOKEN=""
|
||||
|
||||
# ━━━ Notifications ━━━
|
||||
# Discord webhook — leave blank to disable.
|
||||
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
|
||||
HOST1_DISCORD_WEBHOOK=""
|
||||
|
||||
# ━━━ Partnership ━━━
|
||||
# HOST1 is always the owner (source of truth) unless --transfer has been run.
|
||||
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
|
||||
|
||||
# Auth containers reconfigured on onboard/offboard.
|
||||
# Format: "ContainerName|WebUIPort"
|
||||
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
|
||||
# On offboard → WebUI pointed back at localhost
|
||||
HOST1_PARTNERSHIP_AUTH_WEBUIS=(
|
||||
"NginxProxyManager|81"
|
||||
"Lldap-Gmer4Lfe|17170"
|
||||
"Authelia|9091"
|
||||
"Authelia-Secondary|9092"
|
||||
)
|
||||
|
||||
# XML templates (from this server's templates-user/) pushed to mirror during onboard.
|
||||
# These become the mirror's active auth stack, backed by the rsync-synced appdata.
|
||||
# Update filename if Lldap is renamed to drop the host suffix.
|
||||
HOST1_PARTNERSHIP_AUTH_STACK=(
|
||||
# Dependencies first — Mariadb/Redis must be healthy before Authelia starts
|
||||
"my-Mariadb-Authelia.xml"
|
||||
"my-Mariadb-Authelia-Secondary.xml"
|
||||
"my-Redis-Authelia.xml"
|
||||
"my-Redis-Authelia-Secondary.xml"
|
||||
# Auth apps — deployed after their deps are confirmed healthy
|
||||
"my-Authelia.xml"
|
||||
"my-Authelia-Secondary.xml"
|
||||
"my-NginxProxyManager.xml"
|
||||
"my-Lldap-Gmer4Lfe.xml"
|
||||
# Source of truth — must be available on HOST2 independently of the auth stack
|
||||
"my-Gitea.xml"
|
||||
)
|
||||
|
||||
# XML templates pushed to mirror for the arr stack during onboard.
|
||||
# Deps (e.g. databases) first if any — same ordering rule as auth stack.
|
||||
HOST1_PARTNERSHIP_ARR_STACK=(
|
||||
# "my-Sonarr.xml"
|
||||
# "my-Radarr.xml"
|
||||
# "my-Lidarr.xml"
|
||||
# "my-Prowlarr.xml"
|
||||
# "my-Bazarr.xml"
|
||||
)
|
||||
|
||||
# Paths HOST2 should collect during the grace window after offboard.
|
||||
# Notified on offboard — no auto-deletion, HOST2 must collect manually within PARTNERSHIP_GRACE_HOURS.
|
||||
HOST1_PARTNERSHIP_MIRROR_BACKUPS=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
|
||||
)
|
||||
|
||||
# Containers parked on this server when partnership is active.
|
||||
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
|
||||
HOST1_PARTNERSHIP_OWN_CONTAINERS=(
|
||||
# "Emby"
|
||||
# "NginxProxyManager"
|
||||
)
|
||||
|
||||
# Emby admin provisioning — toggle is owner-only, credentials are per-host.
|
||||
# Owner enables/disables the feature. Each host sets the account they want on the shared Emby.
|
||||
# On onboard: owner reads mirror's HOST*_PARTNERSHIP_EMBY_ADMIN_* and creates that account.
|
||||
# On offboard: account is deleted. Username collision → onboard exits with error.
|
||||
HOST1_PARTNERSHIP_PROVISION_EMBY_ADMIN=false # owner controls whether Emby is shared
|
||||
HOST1_PARTNERSHIP_EMBY_PORT=8096
|
||||
HOST1_PARTNERSHIP_EMBY_ADMIN_USER="" # this server's desired Emby username
|
||||
HOST1_PARTNERSHIP_EMBY_ADMIN_PASS="" # this server's desired Emby password
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Daily Sync Shares ━━━
|
||||
# Shares HOST1 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
|
||||
# Mesh model: every node pushes every media share — no ownership, no mirrors.
|
||||
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
|
||||
# arr_cleanup removes true orphans based on local arr state.
|
||||
# Any node can download content to any share — it propagates to all nodes on the next cycle.
|
||||
# Nextcloud is intentionally one-directional (HOST1→HOST2 offsite backup — not arr-managed).
|
||||
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
|
||||
# For shares needing container stops or custom options — add a profile in master.conf.
|
||||
HOST1_DAILY_SYNC_SHARES=(
|
||||
/mnt/user/Books
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/Nextcloud
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Sports
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
# Personal encrypted shares — synced for offsite backup, independent of media shares.
|
||||
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
|
||||
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
|
||||
HOST1_PERSONAL_SHARES=(
|
||||
# /mnt/user/HOST1-Personal # uncomment after creating encrypted dataset
|
||||
)
|
||||
|
||||
# ━━━ Weekly Sync Shares ━━━
|
||||
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
|
||||
# Containers stopped both sides before sync — full clean state guaranteed.
|
||||
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
|
||||
# Order matters — Emby first (larger transfer), then Critical-Data (auth stack).
|
||||
HOST1_WEEKLY_SYNC_SHARES=(
|
||||
"/mnt/user/Media_Server/Emby" # emby profile — full clean mirror
|
||||
"/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile — auth stack
|
||||
)
|
||||
|
||||
# ━━━ Intermediate Sync Shares ━━━
|
||||
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
|
||||
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
|
||||
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
|
||||
HOST1_INTERMEDIATE_SYNC_SHARES=(
|
||||
# Add shares here to enable mid-day rsync
|
||||
# Example: "/mnt/user/Emby_Metadata"
|
||||
)
|
||||
|
||||
# ━━━ Critical Sync Shares ━━━
|
||||
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
|
||||
# Format: "/path/to/share" or "/path/to/share|profile-name"
|
||||
# Order matters — Critical-Data first (auth stack), then Emby dirty sync.
|
||||
HOST1_CRITICAL_SYNC_SHARES=(
|
||||
"/mnt/user/appdata-Fallback/Critical-Data|critical-fallback" # auth dirty sync — stays running
|
||||
"/mnt/user/Media_Server/Emby|emby-fallback" # Emby dirty sync — stays running
|
||||
)
|
||||
|
||||
# ━━━ Backup Verify ━━━
|
||||
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
|
||||
# Leave empty to use HOST1_DAILY_SYNC_SHARES automatically.
|
||||
# Sample size and minimum file size defined in master.conf.
|
||||
HOST1_BACKUP_VERIFY_SHARES=(
|
||||
# leave empty to use HOST1_DAILY_SYNC_SHARES automatically
|
||||
)
|
||||
|
||||
# ━━━ HOST1 Rsync Profile — host1-appdata ━━━
|
||||
# HOST1-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
|
||||
# Use for appdata unique to HOST1 (Organizrv2, VaultWarden, UptimeKuma etc.)
|
||||
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
|
||||
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST1-Appdata --profile=host1-appdata
|
||||
PROFILE_RSYNC_OPTS[host1-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host1-appdata]:-8000}"
|
||||
PROFILE_BW_LIMIT[host1-appdata]=8000
|
||||
PROFILE_RETRY_COUNT[host1-appdata]=3
|
||||
PROFILE_SLEEP[host1-appdata]=300
|
||||
PROFILE_CRITICAL_CONTAINER_NAMES[host1-appdata]="Organizrv2-Gmer4Lfe UptimeKuma-Gmer4Lfe VaultWarden-Gmer4Lfe"
|
||||
PROFILE_DELAYED_CONTAINERS[host1-appdata]=""
|
||||
PROFILE_CONTAINER_DELAY[host1-appdata]=5
|
||||
PROFILE_EXCLUDE_DIRS[host1-appdata]="logs *.tmp"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Docker Daily Restart ━━━
|
||||
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
|
||||
# Dispatcharr degrades over time without restart — daily is intentional, not just housekeeping.
|
||||
# Order matters — auth stack first, then media services.
|
||||
HOST1_DAILY_RESTART_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Lldap-Gmer4Lfe"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
"Dispatcharr-Iptv-Users"
|
||||
"Dispatcharr" # Live TV scheduler — degrades without daily restart
|
||||
"Dispatcharr-Basic"
|
||||
"ErsatzTV-Emby"
|
||||
"Slskd" # Soulseek connection drops after extended uptime; restart refreshes share index
|
||||
)
|
||||
|
||||
# ━━━ Docker Weekly Restart ━━━
|
||||
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
|
||||
# Containers already stopped for weekly sync — restart adds zero extra downtime.
|
||||
HOST1_WEEKLY_RESTART_CONTAINERS=(
|
||||
"NextCloud"
|
||||
"Organizrv2-Gmer4Lfe"
|
||||
"AdGuard-Home"
|
||||
"Immich-Gmer4Lfe"
|
||||
)
|
||||
|
||||
# ━━━ Docker Watchdog ━━━
|
||||
# Per-HOST1 container configuration for docker_watchdog.sh.
|
||||
# Shared thresholds and toggles live in master.conf.
|
||||
|
||||
# Memory hard limits in MB — immediate restart if exceeded.
|
||||
# Set at "container is clearly broken" not "container is busy".
|
||||
# 20GB=20480 18GB=18432 16GB=16384 12GB=12288 8GB=8192 4GB=4096 2GB=2048 1GB=1024
|
||||
declare -A HOST1_WATCHDOG_CONTAINERS=(
|
||||
["Emby"]=20480 # 20GB — large library + active transcodes
|
||||
["LidaTube"]=6144 # 6GB — memory leak over time
|
||||
["Tdarr"]=6144 # 6GB — encoding is memory intensive
|
||||
["Code-Server"]=1024 # 1GB — should never need more
|
||||
)
|
||||
|
||||
# HTTP health check URLs — checked every cycle, strike system before restart.
|
||||
# Only add containers with a meaningful web interface to check.
|
||||
declare -A HOST1_WATCHDOG_CONTAINER_URLS=(
|
||||
["Emby"]="http://localhost:8096"
|
||||
)
|
||||
|
||||
# Required containers — must always be running on HOST1.
|
||||
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
|
||||
# Listed in dependency order — dependencies before dependents.
|
||||
HOST1_WATCHDOG_REQUIRED_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Lldap-Gmer4Lfe"
|
||||
"Mariadb-Authelia"
|
||||
"Mariadb-Authelia-Secondary"
|
||||
"Redis-Authelia"
|
||||
"Redis-Authelia-Secondary"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
)
|
||||
|
||||
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
|
||||
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
|
||||
HOST1_WATCHDOG_SCAN_IGNORE=(
|
||||
"DashGate"
|
||||
"PIA-WG-Config-Generator"
|
||||
"Aperture"
|
||||
"Aperture-Kids"
|
||||
"pgvector-18-Apeture-Kids"
|
||||
"Pgvector18-Aperture"
|
||||
"emby-test" # broken test container (exit 127 — bad image)
|
||||
)
|
||||
|
||||
# Dependency ordering — skip restarting a container if its dependency is also down.
|
||||
# Prevents watchdog from restarting Authelia before Mariadb is back up.
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime.
|
||||
declare -A HOST1_WATCHDOG_DEPENDENCIES=(
|
||||
["Authelia"]="Mariadb-Authelia Redis-Authelia"
|
||||
["Authelia-Secondary"]="Mariadb-Authelia Redis-Authelia-Secondary"
|
||||
["NextCloud"]="Postgres-NextCloud"
|
||||
)
|
||||
|
||||
# Per-container appdata growth suppress ceilings in MB.
|
||||
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
|
||||
# Use this when a container legitimately has large stable data and you want to guarantee
|
||||
# it never triggers a false-positive growth alert. Growth warnings are suppressed while the
|
||||
# container's dir stays below this ceiling; above it, warnings resume as normal.
|
||||
# 50GB=51200 25GB=25600 20GB=20480 15GB=15360 10GB=10240 5GB=5120
|
||||
declare -A HOST1_WATCHDOG_APPDATA_SIZES=(
|
||||
["Tdarr"]="25600" # 25GB — transcode cache grows legitimately during active jobs
|
||||
["7dtd"]="20480" # 20GB — game server world data, expected to be large
|
||||
)
|
||||
|
||||
# ━━━ Network Watchdog ━━━
|
||||
# Host-specific connectivity config for Watchdogs/System/network_watchdog.sh.
|
||||
HOST1_NETWORK_WATCHDOG_DDNS_DOMAIN="gmer4lfe.com"
|
||||
HOST1_NETWORK_WATCHDOG_DDNS_CONTAINER="Gmer4Lfe.com"
|
||||
HOST1_NETWORK_WATCHDOG_NPM_URL="https://gmer4lfe.com"
|
||||
|
||||
# ━━━ Docker Network Connect ━━━
|
||||
# Containers connected to custom networks at array start by docker_network_connect.sh.
|
||||
# Networks created if they don't exist — idempotent, safe to re-run.
|
||||
HOST1_NETWORK_CONNECT_CONTAINERS=(
|
||||
"memcached"
|
||||
"Npm-CrowdSec"
|
||||
)
|
||||
|
||||
HOST1_NETWORK_CONNECT_NETWORKS=(
|
||||
"high-availability"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ DDNS ━━━
|
||||
# DDNS containers HOST1 manages — started/stopped by fallback.sh per DDNS absolute rules:
|
||||
# Internet loss → stop immediately
|
||||
# Failover → HOST2 starts HOST1's DDNS as Tier 1 (before any other containers)
|
||||
# Handback → stop HOST1's DDNS on HOST2 → rsync → start containers → start local DDNS last
|
||||
HOST1_DDNS_CONTAINERS=(
|
||||
"Gmer4Lfe.com"
|
||||
)
|
||||
|
||||
# ━━━ Internet Loss ━━━
|
||||
# Containers stopped immediately on HOST1 when internet connection is lost.
|
||||
# Prevents external-facing services from operating without connectivity.
|
||||
FALLBACK_HOST1_STOP_ON_NO_NET=(
|
||||
"Gmer4Lfe.com"
|
||||
)
|
||||
|
||||
# ━━━ Fallback Tiers — HOST1 Runs for HOST2 ━━━
|
||||
# Containers HOST1 starts when HOST2 goes down.
|
||||
# Tier 1 is always immediate — vital services cannot wait.
|
||||
# Higher tiers activate after HOST2_TIER*_DELAY minutes (set in host2.conf).
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER1=(
|
||||
"Gmer4Lfe.us"
|
||||
"VaultWarden-Jayred365"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER2=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER3=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER4=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
# ━━━ Tier Delays — HOST1's Containers on HOST2 ━━━
|
||||
# How long HOST1 must be down before each tier activates on HOST2 — in minutes.
|
||||
# Tier 1 is always immediate — no delay var needed.
|
||||
HOST1_TIER2_DELAY=240 # 4 hours — NextCloud, Immich
|
||||
HOST1_TIER3_DELAY=720 # 12 hours — secondary services
|
||||
HOST1_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
|
||||
|
||||
# ━━━ Rsync Writeback — HOST1 Appdata Back on Handback ━━━
|
||||
# Syncs HOST1 appdata BACK to HOST1 when it comes back online after a failover.
|
||||
# Containers stopped before writeback — clean source, no competing writes.
|
||||
#
|
||||
# HOST1_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
|
||||
# is more reliable than dirty sync data for brief outages.
|
||||
HOST1_TIER1_WRITEBACK_DELAY=60 # skip Emby writeback if outage under 1hr
|
||||
|
||||
# Tier 4 automatically syncs HOST1_DAILY_SYNC_SHARES — only list paths NOT in that array.
|
||||
FALLBACK_HOST1_WRITEBACK_TIER1=(
|
||||
"/mnt/user/Media_Server/Emby" # watch states built up during outage
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER2=(
|
||||
"/mnt/user/appdata-Fallback/Important-Data" # NextCloud + Postgres — files added during outage
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER3=(
|
||||
# "location-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER4=(
|
||||
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Media Permissions ━━━
|
||||
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
|
||||
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
|
||||
HOST1_MEDIA_PERMISSION_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/appcache
|
||||
/mnt/user/Books
|
||||
/mnt/user/Downloads
|
||||
/mnt/user/Games
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movie_Recordings
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/Photo
|
||||
/mnt/user/Sports
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Temp_Storage
|
||||
/mnt/user/Tv_Recordings
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/YouTube
|
||||
)
|
||||
|
||||
# ━━━ Media Cleaner ━━━
|
||||
# Folder lists for media_cleaner.sh — two profiles: anime and media.
|
||||
# File patterns shared across all servers — defined in master.conf.
|
||||
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
|
||||
HOST1_ANIME_CLEAN_FOLDERS=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
)
|
||||
|
||||
HOST1_MEDIA_CLEAN_FOLDERS=(
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Sports
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Tv_Shows
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Certificate Monitor ━━━
|
||||
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
|
||||
# Checks the actual certificate served, not what NPM thinks it has.
|
||||
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
|
||||
HOST1_CERT_MONITOR_DOMAINS=(
|
||||
"Gmer4Lfe.com"
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ SMART Health ━━━
|
||||
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
|
||||
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
|
||||
HOST1_SMART_IGNORE_DRIVES=(
|
||||
"sda" # boot USB — SMART not meaningful on flash drives
|
||||
)
|
||||
|
||||
# ━━━ ZFS Report ━━━
|
||||
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
|
||||
# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
|
||||
# Pool health thresholds defined in master.conf.
|
||||
HOST1_ZFS_REPORT_IGNORE_POOLS=(
|
||||
"disk5"
|
||||
"disk6"
|
||||
"disk8"
|
||||
"disk9"
|
||||
"disk10"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
|
||||
# Real-world: 9 streams peaked at ~5.5GB — 10G gives generous headroom on 128GB RAM.
|
||||
HOST1_RAMDISK_SIZE="10G"
|
||||
|
||||
# Usage thresholds — coupled to HOST1_RAMDISK_SIZE, adjust all three together if size changes.
|
||||
# Hysteresis gap (8.5 - 7 = 1.5GB) prevents flip-flop between ramdisk and SSD.
|
||||
HOST1_RAMDISK_WARN_GB=8.5 # flip to SSD when ramdisk usage reaches this
|
||||
HOST1_RAMDISK_LOW_GB=7 # flip back to ramdisk when usage drops to this
|
||||
|
||||
# SSD fallback path — where transcodes land when ramdisk exceeds HOST1_RAMDISK_WARN_GB.
|
||||
# Must be on cache pool — array disks too slow for active transcode writes.
|
||||
HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
|
||||
|
||||
# Media servers sharing the ramdisk transcode space on HOST1.
|
||||
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
|
||||
# Entries with placeholder API keys are skipped automatically.
|
||||
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
|
||||
HOST1_TRANSCODE_SERVERS=(
|
||||
"${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
|
||||
"${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
|
||||
# detect_hosts() selects HOST1 vars when running on HOST1.
|
||||
#
|
||||
# PATH MAPS — container path → host path translation.
|
||||
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
|
||||
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
|
||||
|
||||
# ━━━ Downloaders ━━━
|
||||
# Used by downloaders_reset.sh — runs every 30min via CRITICAL_MAINTENANCE_SCRIPTS.
|
||||
# Clears stuck states, purges old history, prepares each client for a clean cycle.
|
||||
|
||||
# slskd — clears stuck searches, dead transfers, purges expired failed imports.
|
||||
# SLSKD_FAILED_IMPORTS_DIR: where Soularr moves albums Lidarr rejected.
|
||||
HOST1_SLSKD_URL="http://localhost:8980"
|
||||
HOST1_SLSKD_API_KEY="4bF9kL2mNpQrT7vWxYz1A3dEgHjKoRsU"
|
||||
HOST1_SLSKD_FAILED_IMPORTS_DIR="/mnt/user/Temp_Storage/Slskd/completed/failed_imports"
|
||||
|
||||
# SABnzbd
|
||||
HOST1_SABNZBD_URL="http://localhost:8180"
|
||||
HOST1_SABNZBD_API_KEY="8bfefe41d83b4d50883e32859b55ca9a"
|
||||
|
||||
# qBittorrent — deleteFiles=false removes torrent from qBit but leaves files on disk.
|
||||
# Radarr/Sonarr manage actual files independently.
|
||||
HOST1_QBIT_URL="http://localhost:8080"
|
||||
HOST1_QBIT_USERNAME="root"
|
||||
HOST1_QBIT_PASSWORD="Stay0utD!ck"
|
||||
|
||||
# ━━━ Lidarr — HOST1 only ━━━
|
||||
# HOST2 does not run Lidarr — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
|
||||
HOST1_LIDARR_URL="http://localhost:8686"
|
||||
HOST1_LIDARR_API_KEY="b2977e71ef074bc0a0529d9fcce3b2dc"
|
||||
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music-New"
|
||||
HOST1_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
|
||||
HOST1_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
|
||||
|
||||
declare -A HOST1_LIDARR_PATH_MAP=(
|
||||
["/ext-music"]="/mnt/user/Music-New"
|
||||
)
|
||||
|
||||
# ━━━ Sonarr ━━━
|
||||
HOST1_SONARR_URL="http://localhost:8989"
|
||||
HOST1_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
|
||||
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
|
||||
|
||||
# Note: stand-up_comedy in both Sonarr + Radarr — TV specials and movie specials, one folder
|
||||
declare -A HOST1_SONARR_PATH_MAP=(
|
||||
["/tv"]="/mnt/user/Tv_Shows"
|
||||
["/ext-standup-comedy"]="/mnt/user/stand-up_comedy"
|
||||
["/kids tv"]="/mnt/user/Kids_Tv_Shows"
|
||||
["/ext-anime-shows"]="/mnt/user/Anime_Shows-Old"
|
||||
)
|
||||
|
||||
# ━━━ Radarr ━━━
|
||||
HOST1_RADARR_URL="http://localhost:7878"
|
||||
HOST1_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
|
||||
HOST1_TMDB_API_KEY="3dac5e2e49b5540472d2eafec4f01260"
|
||||
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
|
||||
|
||||
# Note: stand-up_comedy in both Radarr + Sonarr — movie specials and TV specials, one folder
|
||||
declare -A HOST1_RADARR_PATH_MAP=(
|
||||
["/movies"]="/mnt/user/Movies"
|
||||
["/kids movies"]="/mnt/user/Kids_Movies"
|
||||
["/ext-stand-up-comedy"]="/mnt/user/stand-up_comedy"
|
||||
["/anime-movies"]="/mnt/user/Anime_Movies-Old"
|
||||
)
|
||||
|
||||
# ━━━ Arr Recovery Toggles ━━━
|
||||
# false = skip that arr on this host — exits cleanly without error
|
||||
HOST1_SONARR_RECOVERY=true
|
||||
HOST1_RADARR_RECOVERY=true
|
||||
HOST1_LIDARR_RECOVERY=true # HOST1 only — exits cleanly on HOST2
|
||||
|
||||
# ==============================================================================================
|
||||
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Per-host check toggles and NIC config for system_watchdog.sh.
|
||||
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
|
||||
# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
|
||||
#
|
||||
# Three-tier response — all critical checks enabled by default on HOST1:
|
||||
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
|
||||
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
|
||||
# Tier 3 (standard strike system): everything else
|
||||
#
|
||||
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
|
||||
|
||||
# ━━━ Primary NIC ━━━
|
||||
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
|
||||
# Common values: eth0, bond0, br0, eno1
|
||||
HOST1_SYS_WATCHDOG_NIC="eth0"
|
||||
|
||||
# ━━━ Tier 1 — Critical Checks ━━━
|
||||
# These bypass the strike system — a single hit triggers immediate reboot.
|
||||
# Disabling any of these is not recommended — they protect against acute system failure.
|
||||
|
||||
# Docker daemon unresponsive → try restart, reboot if restart fails.
|
||||
# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
|
||||
HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
|
||||
|
||||
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
|
||||
# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.
|
||||
# Standard 95% threshold still uses strike system — only 99%+ is critical tier.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ROOTFS=true
|
||||
|
||||
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
|
||||
# A kernel oops means the kernel ran with a corrupted state — stability is not guaranteed.
|
||||
HOST1_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
|
||||
|
||||
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
|
||||
# At 95% FD: new connections fail, Docker can't spawn processes, SSH drops.
|
||||
HOST1_SYS_WATCHDOG_CHECK_FD=true
|
||||
|
||||
# /boot read-only detected → reboot immediately.
|
||||
# Unexpected read-only /boot means state files and config writes are silently failing.
|
||||
# Fallback state, watchdog reboot log, and lock files all go stale silently.
|
||||
HOST1_SYS_WATCHDOG_CHECK_BOOT=true
|
||||
|
||||
# ━━━ Tier 2 — Urgent OOM Check ━━━
|
||||
# Bypass strikes when RAM is critically low AND OOM kill rate confirms active crisis.
|
||||
# Both must be enabled for Tier 2 bypass to function — disable either to always use strikes.
|
||||
|
||||
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
|
||||
# Also provides diagnostic context in reboot messages (which processes were killed).
|
||||
HOST1_SYS_WATCHDOG_CHECK_OOM=true
|
||||
|
||||
# Free RAM check — required for both Tier 2 bypass and RAM tier logic.
|
||||
# Tiers: MEM_WARN_GB(10) → notify | MEM_SHUTDOWN_GB(6) → stop containers | MEM_GB(4) → strikes
|
||||
HOST1_SYS_WATCHDOG_CHECK_RAM=true
|
||||
|
||||
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
|
||||
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action is taken.
|
||||
# Single spikes are ignored — sustained problems trigger reboot.
|
||||
|
||||
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
|
||||
# Log spam (Docker log storms, syslog loops) fills rootfs — indicates something broken.
|
||||
HOST1_SYS_WATCHDOG_CHECK_LOG=true
|
||||
|
||||
# ZFS ARC memory pinned above SYS_WATCHDOG_ARC_PINNED_PCT after cache drop.
|
||||
# Enabled on HOST1 — ZFS cache pools actively used. Disable on hosts without ZFS.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ARC=true
|
||||
|
||||
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
|
||||
# Sustained high temp causes kernel throttling or panic. Requires lm-sensors.
|
||||
HOST1_SYS_WATCHDOG_CHECK_CPU_TEMP=true
|
||||
|
||||
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
|
||||
# DISABLED on HOST1 — Tdarr and Emby cause legitimate sustained load spikes during encoding.
|
||||
# Enable on idle servers or adjust SYS_WATCHDOG_LOAD_MULTIPLIER if load is always high.
|
||||
HOST1_SYS_WATCHDOG_CHECK_LOAD=false
|
||||
|
||||
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
|
||||
# Large zombie counts indicate serious process management failure — something is stuck.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ZOMBIES=true
|
||||
|
||||
# Check docker_watchdog.sh persistent skip list — required containers on skip list.
|
||||
# Cross-watchdog coordination: if docker_watchdog gave up, system_watchdog escalates.
|
||||
# ENABLED — HOST1 fully built and operational, skip list is meaningful.
|
||||
HOST1_SYS_WATCHDOG_CHECK_CONTAINERS=true
|
||||
|
||||
# /tmp filesystem usage above SYS_WATCHDOG_TMP_PCT with auto-clear attempt.
|
||||
# Script tries to clear aged /tmp files first — only strikes if clear fails.
|
||||
# Lock files, rsync temp files, and Docker ops use /tmp — 100% means lock failures.
|
||||
HOST1_SYS_WATCHDOG_CHECK_TMP=true
|
||||
|
||||
# Array disk error count delta in /proc/mdstat — accumulating errors = disk failing now.
|
||||
# Triggers on SYS_WATCHDOG_MDSTAT_ERROR_LIMIT new errors in one cycle.
|
||||
HOST1_SYS_WATCHDOG_CHECK_MDSTAT=true
|
||||
|
||||
# Primary NIC operstate — detects NIC going down (physical or driver failure).
|
||||
# Uses HOST1_SYS_WATCHDOG_NIC above. Strike system — brief flaps don't trigger reboot.
|
||||
HOST1_SYS_WATCHDOG_CHECK_NETWORK=true
|
||||
|
||||
# sshd running check — attempts restart before escalating.
|
||||
# sshd down = no remote access. Script tries rc.sshd start, notifies, strikes on failure.
|
||||
HOST1_SYS_WATCHDOG_CHECK_SSHD=true
|
||||
|
||||
# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
|
||||
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
|
||||
# Enable only if HOST1 has no CPU-intensive workloads.
|
||||
HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
|
||||
|
||||
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
|
||||
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
|
||||
HOST1_RW_PAUSE_CONTAINERS=(
|
||||
"Huntarr" # arr search automation — safe to suspend
|
||||
"Cleanuparr" # download cleanup — safe to suspend
|
||||
"Healarr" # arr health checks — safe to suspend
|
||||
"Soularr" # Slskd automation — background only
|
||||
"ChannelTube" # YouTube archiver — background only
|
||||
"Pinchflat" # YouTube archiver — background only
|
||||
)
|
||||
|
||||
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
|
||||
# Full stop — these are optional/heavy services that free significant RAM when stopped.
|
||||
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
|
||||
HOST1_RW_STOP_CONTAINERS=(
|
||||
"LocalAI" # GPU/CPU heavy — largest RAM consumer when idle
|
||||
"7DaysToDie" # game server — optional
|
||||
"V-Rising" # game server — optional
|
||||
"Code-Server" # IDE — not needed during pressure events
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
@@ -0,0 +1,792 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ========================== HOST1 CONFIGURATION — unRAID-Gmer4Lfe ============================
|
||||
# ==============================================================================================
|
||||
# HOST1-specific variables — credentials, container names, share paths, failover lists.
|
||||
# Sourced after master.conf — values here extend shared profile arrays and add HOST1-specific
|
||||
# identity, credentials, and container configuration.
|
||||
#
|
||||
# Sparse checkout (git) ensures HOST2 never receives this file.
|
||||
# HOST2 never sees HOST1 credentials — clean separation at the file level.
|
||||
#
|
||||
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
|
||||
# DO NOT put HOST2 variables here — they belong in host2.conf.
|
||||
#
|
||||
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
|
||||
# IDENTITY hostname, SSH key
|
||||
# EMBY container name, URL, API key
|
||||
# NOTIFICATIONS Discord webhook
|
||||
# PARTNERSHIP auth containers, backup paths
|
||||
#
|
||||
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
|
||||
# DAILY SYNC SHARES media shares HOST1 owns and pushes to HOST2
|
||||
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
|
||||
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
|
||||
# BACKUP VERIFY shares for checksum verification against remote
|
||||
# HOST1 RSYNC PROFILE host1-appdata profile for HOST1-specific appdata syncs
|
||||
#
|
||||
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
|
||||
# DOCKER DAILY RESTART containers restarted daily
|
||||
# DOCKER WEEKLY RESTART containers restarted weekly
|
||||
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
|
||||
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
|
||||
#
|
||||
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
|
||||
# DDNS DDNS containers managed by HOST1
|
||||
# INTERNET LOSS containers stopped when internet is lost
|
||||
# FALLBACK TIERS what HOST1 runs for HOST2 per tier
|
||||
# TIER DELAYS how long HOST1 must be down before each tier activates on HOST2
|
||||
# RSYNC WRITEBACK HOST1 appdata synced back on handback
|
||||
#
|
||||
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
|
||||
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
|
||||
# MEDIA CLEANER folder lists for media_cleaner.sh
|
||||
#
|
||||
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
|
||||
# CERTIFICATE MONITOR domains checked for SSL expiry
|
||||
# SMART HEALTH drives to skip in SMART monitoring
|
||||
# ZFS REPORT pools to exclude from ZFS health report
|
||||
#
|
||||
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
|
||||
# TRANSCODES ramdisk size, thresholds, SSD path, server array
|
||||
#
|
||||
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
|
||||
# DOWNLOADERS slskd, SABnzbd, qBittorrent credentials and URLs
|
||||
# LIDARR URL, API key, path map
|
||||
# SONARR URL, API key, path map
|
||||
# RADARR URL, API key, path map
|
||||
# ARR RECOVERY per-arr recovery toggles
|
||||
#
|
||||
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
|
||||
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
|
||||
#
|
||||
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
|
||||
# RESOURCE MANAGER containers paused/stopped under memory pressure
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
# ==============================================================================================
|
||||
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Identity ━━━
|
||||
# HOST1 hostname lives in master.conf (not a credential — safe for all servers).
|
||||
# SSH key used for all server-to-server operations — rsync, failover container commands.
|
||||
# Must be in /root/.ssh/ and authorised in HOST2's /root/.ssh/authorized_keys.
|
||||
HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation"
|
||||
HOST1_OWNER="gmer4lfe"
|
||||
HOST1_OWNER_EMAIL="gmer4lfe@gmail.com"
|
||||
|
||||
# ━━━ Emby ━━━
|
||||
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
|
||||
# weekly_sync_maintenance.sh, and HOST1_TRANSCODE_SERVERS below.
|
||||
# API key: Emby Dashboard → API Keys → + New Key
|
||||
HOST1_EMBY_CONTAINER="Emby"
|
||||
HOST1_EMBY_URL="http://localhost:8096"
|
||||
HOST1_EMBY_API_KEY="0c27448d93a7431f9ac63569f7655829"
|
||||
|
||||
# ━━━ Jellyfin ━━━
|
||||
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
|
||||
HOST1_JELLYFIN_CONTAINER="Jellyfin"
|
||||
HOST1_JELLYFIN_URL="http://localhost:8095"
|
||||
HOST1_JELLYFIN_API_KEY="4e820e7df74c4933acec212b1996314e"
|
||||
|
||||
# ━━━ Gitea ━━━
|
||||
# Personal access token for gitea_ssh_setup.sh — registers this server's SSH public key
|
||||
# with Gitea so git operations use key auth instead of passwords.
|
||||
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user
|
||||
HOST1_GITEA_API_TOKEN=""
|
||||
|
||||
# ━━━ Notifications ━━━
|
||||
# Discord webhook — leave blank to disable.
|
||||
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
|
||||
HOST1_DISCORD_WEBHOOK=""
|
||||
|
||||
# ━━━ Partnership ━━━
|
||||
# HOST1 is always the owner (source of truth) unless --transfer has been run.
|
||||
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
|
||||
|
||||
# Auth containers reconfigured on onboard/offboard.
|
||||
# Format: "ContainerName|WebUIPort"
|
||||
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
|
||||
# On offboard → WebUI pointed back at localhost
|
||||
HOST1_PARTNERSHIP_AUTH_WEBUIS=(
|
||||
"NginxProxyManager|81"
|
||||
"Lldap-Gmer4Lfe|17170"
|
||||
"Authelia|9091"
|
||||
"Authelia-Secondary|9092"
|
||||
)
|
||||
|
||||
# XML templates (from this server's templates-user/) pushed to mirror during onboard.
|
||||
# These become the mirror's active auth stack, backed by the rsync-synced appdata.
|
||||
# Update filename if Lldap is renamed to drop the host suffix.
|
||||
HOST1_PARTNERSHIP_AUTH_STACK=(
|
||||
# Dependencies first — Mariadb/Redis must be healthy before Authelia starts
|
||||
"my-Mariadb-Authelia.xml"
|
||||
"my-Mariadb-Authelia-Secondary.xml"
|
||||
"my-Redis-Authelia.xml"
|
||||
"my-Redis-Authelia-Secondary.xml"
|
||||
# Auth apps — deployed after their deps are confirmed healthy
|
||||
"my-Authelia.xml"
|
||||
"my-Authelia-Secondary.xml"
|
||||
"my-NginxProxyManager.xml"
|
||||
"my-Lldap-Gmer4Lfe.xml"
|
||||
# Source of truth — must be available on HOST2 independently of the auth stack
|
||||
"my-Gitea.xml"
|
||||
)
|
||||
|
||||
# XML templates pushed to mirror for the arr stack during onboard.
|
||||
# Deps (e.g. databases) first if any — same ordering rule as auth stack.
|
||||
HOST1_PARTNERSHIP_ARR_STACK=(
|
||||
# "my-Sonarr.xml"
|
||||
# "my-Radarr.xml"
|
||||
# "my-Lidarr.xml"
|
||||
# "my-Prowlarr.xml"
|
||||
# "my-Bazarr.xml"
|
||||
)
|
||||
|
||||
# Paths HOST2 should collect during the grace window after offboard.
|
||||
# Notified on offboard — no auto-deletion, HOST2 must collect manually within PARTNERSHIP_GRACE_HOURS.
|
||||
HOST1_PARTNERSHIP_MIRROR_BACKUPS=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
|
||||
)
|
||||
|
||||
# Containers parked on this server when partnership is active.
|
||||
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
|
||||
HOST1_PARTNERSHIP_OWN_CONTAINERS=(
|
||||
# "Emby"
|
||||
# "NginxProxyManager"
|
||||
)
|
||||
|
||||
# Emby admin provisioning — toggle is owner-only, credentials are per-host.
|
||||
# Owner enables/disables the feature. Each host sets the account they want on the shared Emby.
|
||||
# On onboard: owner reads mirror's HOST*_PARTNERSHIP_EMBY_ADMIN_* and creates that account.
|
||||
# On offboard: account is deleted. Username collision → onboard exits with error.
|
||||
HOST1_PARTNERSHIP_PROVISION_EMBY_ADMIN=false # owner controls whether Emby is shared
|
||||
HOST1_PARTNERSHIP_EMBY_PORT=8096
|
||||
HOST1_PARTNERSHIP_EMBY_ADMIN_USER="" # this server's desired Emby username
|
||||
HOST1_PARTNERSHIP_EMBY_ADMIN_PASS="" # this server's desired Emby password
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Daily Sync Shares ━━━
|
||||
# Shares HOST1 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
|
||||
# Mesh model: every node pushes every media share — no ownership, no mirrors.
|
||||
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
|
||||
# arr_cleanup removes true orphans based on local arr state.
|
||||
# Any node can download content to any share — it propagates to all nodes on the next cycle.
|
||||
# Nextcloud is intentionally one-directional (HOST1→HOST2 offsite backup — not arr-managed).
|
||||
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
|
||||
# For shares needing container stops or custom options — add a profile in master.conf.
|
||||
HOST1_DAILY_SYNC_SHARES=(
|
||||
/mnt/user/Books
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/Nextcloud
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Sports
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
# Personal encrypted shares — synced for offsite backup, independent of media shares.
|
||||
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
|
||||
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
|
||||
HOST1_PERSONAL_SHARES=(
|
||||
# /mnt/user/HOST1-Personal # uncomment after creating encrypted dataset
|
||||
)
|
||||
|
||||
# ━━━ Weekly Sync Shares ━━━
|
||||
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
|
||||
# Containers stopped both sides before sync — full clean state guaranteed.
|
||||
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
|
||||
# Order matters — Emby first (larger transfer), then Critical-Data (auth stack).
|
||||
HOST1_WEEKLY_SYNC_SHARES=(
|
||||
"/mnt/user/Media_Server/Emby" # emby profile — full clean mirror
|
||||
"/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile — auth stack
|
||||
)
|
||||
|
||||
# ━━━ Intermediate Sync Shares ━━━
|
||||
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
|
||||
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
|
||||
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
|
||||
HOST1_INTERMEDIATE_SYNC_SHARES=(
|
||||
# Add shares here to enable mid-day rsync
|
||||
# Example: "/mnt/user/Emby_Metadata"
|
||||
)
|
||||
|
||||
# ━━━ Critical Sync Shares ━━━
|
||||
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
|
||||
# Format: "/path/to/share" or "/path/to/share|profile-name"
|
||||
# Order matters — Critical-Data first (auth stack), then Emby dirty sync.
|
||||
HOST1_CRITICAL_SYNC_SHARES=(
|
||||
"/mnt/user/appdata-Fallback/Critical-Data|critical-fallback" # auth dirty sync — stays running
|
||||
"/mnt/user/Media_Server/Emby|emby-fallback" # Emby dirty sync — stays running
|
||||
)
|
||||
|
||||
# ━━━ Backup Verify ━━━
|
||||
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
|
||||
# Leave empty to use HOST1_DAILY_SYNC_SHARES automatically.
|
||||
# Sample size and minimum file size defined in master.conf.
|
||||
HOST1_BACKUP_VERIFY_SHARES=(
|
||||
# leave empty to use HOST1_DAILY_SYNC_SHARES automatically
|
||||
)
|
||||
|
||||
# ━━━ HOST1 Rsync Profile — host1-appdata ━━━
|
||||
# HOST1-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
|
||||
# Use for appdata unique to HOST1 (Organizrv2, VaultWarden, UptimeKuma etc.)
|
||||
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
|
||||
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST1-Appdata --profile=host1-appdata
|
||||
PROFILE_RSYNC_OPTS[host1-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host1-appdata]:-8000}"
|
||||
PROFILE_BW_LIMIT[host1-appdata]=8000
|
||||
PROFILE_RETRY_COUNT[host1-appdata]=3
|
||||
PROFILE_SLEEP[host1-appdata]=300
|
||||
PROFILE_CRITICAL_CONTAINER_NAMES[host1-appdata]="Organizrv2-Gmer4Lfe UptimeKuma-Gmer4Lfe VaultWarden-Gmer4Lfe"
|
||||
PROFILE_DELAYED_CONTAINERS[host1-appdata]=""
|
||||
PROFILE_CONTAINER_DELAY[host1-appdata]=5
|
||||
PROFILE_EXCLUDE_DIRS[host1-appdata]="logs *.tmp"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Docker Daily Restart ━━━
|
||||
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
|
||||
# Dispatcharr degrades over time without restart — daily is intentional, not just housekeeping.
|
||||
# Order matters — auth stack first, then media services.
|
||||
HOST1_DAILY_RESTART_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Lldap-Gmer4Lfe"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
"Dispatcharr-Iptv-Users"
|
||||
"Dispatcharr" # Live TV scheduler — degrades without daily restart
|
||||
"Dispatcharr-Basic"
|
||||
"ErsatzTV-Emby"
|
||||
"Slskd" # Soulseek connection drops after extended uptime; restart refreshes share index
|
||||
)
|
||||
|
||||
# ━━━ Docker Weekly Restart ━━━
|
||||
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
|
||||
# Containers already stopped for weekly sync — restart adds zero extra downtime.
|
||||
HOST1_WEEKLY_RESTART_CONTAINERS=(
|
||||
"NextCloud"
|
||||
"Organizrv2-Gmer4Lfe"
|
||||
"AdGuard-Home"
|
||||
"Immich-Gmer4Lfe"
|
||||
)
|
||||
|
||||
# ━━━ Docker Watchdog ━━━
|
||||
# Per-HOST1 container configuration for docker_watchdog.sh.
|
||||
# Shared thresholds and toggles live in master.conf.
|
||||
|
||||
# Memory hard limits in MB — immediate restart if exceeded.
|
||||
# Set at "container is clearly broken" not "container is busy".
|
||||
# 20GB=20480 18GB=18432 16GB=16384 12GB=12288 8GB=8192 4GB=4096 2GB=2048 1GB=1024
|
||||
declare -A HOST1_WATCHDOG_CONTAINERS=(
|
||||
["Emby"]=20480 # 20GB — large library + active transcodes
|
||||
["LidaTube"]=6144 # 6GB — memory leak over time
|
||||
["Tdarr"]=6144 # 6GB — encoding is memory intensive
|
||||
["Code-Server"]=1024 # 1GB — should never need more
|
||||
)
|
||||
|
||||
# HTTP health check URLs — checked every cycle, strike system before restart.
|
||||
# Only add containers with a meaningful web interface to check.
|
||||
declare -A HOST1_WATCHDOG_CONTAINER_URLS=(
|
||||
["Emby"]="http://localhost:8096"
|
||||
)
|
||||
|
||||
# Required containers — must always be running on HOST1.
|
||||
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
|
||||
# Listed in dependency order — dependencies before dependents.
|
||||
HOST1_WATCHDOG_REQUIRED_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Lldap-Gmer4Lfe"
|
||||
"Mariadb-Authelia"
|
||||
"Mariadb-Authelia-Secondary"
|
||||
"Redis-Authelia"
|
||||
"Redis-Authelia-Secondary"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
)
|
||||
|
||||
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
|
||||
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
|
||||
HOST1_WATCHDOG_SCAN_IGNORE=(
|
||||
"DashGate"
|
||||
"PIA-WG-Config-Generator"
|
||||
"Aperture"
|
||||
"Aperture-Kids"
|
||||
"pgvector-18-Apeture-Kids"
|
||||
"Pgvector18-Aperture"
|
||||
"emby-test" # broken test container (exit 127 — bad image)
|
||||
)
|
||||
|
||||
# Dependency ordering — skip restarting a container if its dependency is also down.
|
||||
# Prevents watchdog from restarting Authelia before Mariadb is back up.
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime.
|
||||
declare -A HOST1_WATCHDOG_DEPENDENCIES=(
|
||||
["Authelia"]="Mariadb-Authelia Redis-Authelia"
|
||||
["Authelia-Secondary"]="Mariadb-Authelia Redis-Authelia-Secondary"
|
||||
["NextCloud"]="Postgres-NextCloud"
|
||||
)
|
||||
|
||||
# Per-container appdata growth suppress ceilings in MB.
|
||||
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
|
||||
# Use this when a container legitimately has large stable data and you want to guarantee
|
||||
# it never triggers a false-positive growth alert. Growth warnings are suppressed while the
|
||||
# container's dir stays below this ceiling; above it, warnings resume as normal.
|
||||
# 50GB=51200 25GB=25600 20GB=20480 15GB=15360 10GB=10240 5GB=5120
|
||||
declare -A HOST1_WATCHDOG_APPDATA_SIZES=(
|
||||
["Tdarr"]="25600" # 25GB — transcode cache grows legitimately during active jobs
|
||||
["7dtd"]="20480" # 20GB — game server world data, expected to be large
|
||||
)
|
||||
|
||||
# ━━━ Network Watchdog ━━━
|
||||
# Host-specific connectivity config for Watchdogs/System/network_watchdog.sh.
|
||||
HOST1_NETWORK_WATCHDOG_DDNS_DOMAIN="gmer4lfe.com"
|
||||
HOST1_NETWORK_WATCHDOG_DDNS_CONTAINER="Gmer4Lfe.com"
|
||||
HOST1_NETWORK_WATCHDOG_NPM_URL="https://gmer4lfe.com"
|
||||
|
||||
# ━━━ Docker Network Connect ━━━
|
||||
# Containers connected to custom networks at array start by docker_network_connect.sh.
|
||||
# Networks created if they don't exist — idempotent, safe to re-run.
|
||||
HOST1_NETWORK_CONNECT_CONTAINERS=(
|
||||
"memcached"
|
||||
"Npm-CrowdSec"
|
||||
)
|
||||
|
||||
HOST1_NETWORK_CONNECT_NETWORKS=(
|
||||
"high-availability"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ DDNS ━━━
|
||||
# DDNS containers HOST1 manages — started/stopped by fallback.sh per DDNS absolute rules:
|
||||
# Internet loss → stop immediately
|
||||
# Failover → HOST2 starts HOST1's DDNS as Tier 1 (before any other containers)
|
||||
# Handback → stop HOST1's DDNS on HOST2 → rsync → start containers → start local DDNS last
|
||||
HOST1_DDNS_CONTAINERS=(
|
||||
"Gmer4Lfe.com"
|
||||
)
|
||||
|
||||
# ━━━ Internet Loss ━━━
|
||||
# Containers stopped immediately on HOST1 when internet connection is lost.
|
||||
# Prevents external-facing services from operating without connectivity.
|
||||
FALLBACK_HOST1_STOP_ON_NO_NET=(
|
||||
"Gmer4Lfe.com"
|
||||
)
|
||||
|
||||
# ━━━ Fallback Tiers — HOST1 Runs for HOST2 ━━━
|
||||
# Containers HOST1 starts when HOST2 goes down.
|
||||
# Tier 1 is always immediate — vital services cannot wait.
|
||||
# Higher tiers activate after HOST2_TIER*_DELAY minutes (set in host2.conf).
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER1=(
|
||||
"Gmer4Lfe.us"
|
||||
"VaultWarden-Jayred365"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER2=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER3=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER4=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
# ━━━ Tier Delays — HOST1's Containers on HOST2 ━━━
|
||||
# How long HOST1 must be down before each tier activates on HOST2 — in minutes.
|
||||
# Tier 1 is always immediate — no delay var needed.
|
||||
HOST1_TIER2_DELAY=240 # 4 hours — NextCloud, Immich
|
||||
HOST1_TIER3_DELAY=720 # 12 hours — secondary services
|
||||
HOST1_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
|
||||
|
||||
# ━━━ Rsync Writeback — HOST1 Appdata Back on Handback ━━━
|
||||
# Syncs HOST1 appdata BACK to HOST1 when it comes back online after a failover.
|
||||
# Containers stopped before writeback — clean source, no competing writes.
|
||||
#
|
||||
# HOST1_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
|
||||
# is more reliable than dirty sync data for brief outages.
|
||||
HOST1_TIER1_WRITEBACK_DELAY=60 # skip Emby writeback if outage under 1hr
|
||||
|
||||
# Tier 4 automatically syncs HOST1_DAILY_SYNC_SHARES — only list paths NOT in that array.
|
||||
FALLBACK_HOST1_WRITEBACK_TIER1=(
|
||||
"/mnt/user/Media_Server/Emby" # watch states built up during outage
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER2=(
|
||||
"/mnt/user/appdata-Fallback/Important-Data" # NextCloud + Postgres — files added during outage
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER3=(
|
||||
# "location-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER4=(
|
||||
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Media Permissions ━━━
|
||||
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
|
||||
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
|
||||
HOST1_MEDIA_PERMISSION_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/appcache
|
||||
/mnt/user/Books
|
||||
/mnt/user/Downloads
|
||||
/mnt/user/Games
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movie_Recordings
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/Photo
|
||||
/mnt/user/Sports
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Temp_Storage
|
||||
/mnt/user/Tv_Recordings
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/YouTube
|
||||
)
|
||||
|
||||
# ━━━ Media Cleaner ━━━
|
||||
# Folder lists for media_cleaner.sh — two profiles: anime and media.
|
||||
# File patterns shared across all servers — defined in master.conf.
|
||||
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
|
||||
HOST1_ANIME_CLEAN_FOLDERS=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
)
|
||||
|
||||
HOST1_MEDIA_CLEAN_FOLDERS=(
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Sports
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Tv_Shows
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Certificate Monitor ━━━
|
||||
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
|
||||
# Checks the actual certificate served, not what NPM thinks it has.
|
||||
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
|
||||
HOST1_CERT_MONITOR_DOMAINS=(
|
||||
"Gmer4Lfe.com"
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ SMART Health ━━━
|
||||
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
|
||||
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
|
||||
HOST1_SMART_IGNORE_DRIVES=(
|
||||
"sda" # boot USB — SMART not meaningful on flash drives
|
||||
)
|
||||
|
||||
# ━━━ ZFS Report ━━━
|
||||
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
|
||||
# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
|
||||
# Pool health thresholds defined in master.conf.
|
||||
HOST1_ZFS_REPORT_IGNORE_POOLS=(
|
||||
"disk5"
|
||||
"disk6"
|
||||
"disk8"
|
||||
"disk9"
|
||||
"disk10"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
|
||||
# Real-world: 9 streams peaked at ~5.5GB — 10G gives generous headroom on 128GB RAM.
|
||||
HOST1_RAMDISK_SIZE="10G"
|
||||
|
||||
# Usage thresholds — coupled to HOST1_RAMDISK_SIZE, adjust all three together if size changes.
|
||||
# Hysteresis gap (8.5 - 7 = 1.5GB) prevents flip-flop between ramdisk and SSD.
|
||||
HOST1_RAMDISK_WARN_GB=8.5 # flip to SSD when ramdisk usage reaches this
|
||||
HOST1_RAMDISK_LOW_GB=7 # flip back to ramdisk when usage drops to this
|
||||
|
||||
# SSD fallback path — where transcodes land when ramdisk exceeds HOST1_RAMDISK_WARN_GB.
|
||||
# Must be on cache pool — array disks too slow for active transcode writes.
|
||||
HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
|
||||
|
||||
# Media servers sharing the ramdisk transcode space on HOST1.
|
||||
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
|
||||
# Entries with placeholder API keys are skipped automatically.
|
||||
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
|
||||
HOST1_TRANSCODE_SERVERS=(
|
||||
"${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
|
||||
"${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
|
||||
# detect_hosts() selects HOST1 vars when running on HOST1.
|
||||
#
|
||||
# PATH MAPS — container path → host path translation.
|
||||
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
|
||||
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
|
||||
|
||||
# ━━━ Downloaders ━━━
|
||||
# Used by downloaders_reset.sh — runs every 30min via CRITICAL_MAINTENANCE_SCRIPTS.
|
||||
# Clears stuck states, purges old history, prepares each client for a clean cycle.
|
||||
|
||||
# slskd — clears stuck searches, dead transfers, purges expired failed imports.
|
||||
# SLSKD_FAILED_IMPORTS_DIR: where Soularr moves albums Lidarr rejected.
|
||||
HOST1_SLSKD_URL="http://localhost:8980"
|
||||
HOST1_SLSKD_API_KEY="4bF9kL2mNpQrT7vWxYz1A3dEgHjKoRsU"
|
||||
HOST1_SLSKD_FAILED_IMPORTS_DIR="/mnt/user/Temp_Storage/Slskd/completed/failed_imports"
|
||||
|
||||
# SABnzbd
|
||||
HOST1_SABNZBD_URL="http://localhost:8180"
|
||||
HOST1_SABNZBD_API_KEY="8bfefe41d83b4d50883e32859b55ca9a"
|
||||
|
||||
# qBittorrent — deleteFiles=false removes torrent from qBit but leaves files on disk.
|
||||
# Radarr/Sonarr manage actual files independently.
|
||||
HOST1_QBIT_URL="http://localhost:8080"
|
||||
HOST1_QBIT_USERNAME="root"
|
||||
HOST1_QBIT_PASSWORD="Stay0utD!ck"
|
||||
|
||||
# ━━━ Lidarr — HOST1 only ━━━
|
||||
# HOST2 does not run Lidarr — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
|
||||
HOST1_LIDARR_URL="http://localhost:8686"
|
||||
HOST1_LIDARR_API_KEY="b2977e71ef074bc0a0529d9fcce3b2dc"
|
||||
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music-New"
|
||||
HOST1_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
|
||||
HOST1_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
|
||||
|
||||
declare -A HOST1_LIDARR_PATH_MAP=(
|
||||
["/ext-music"]="/mnt/user/Music-New"
|
||||
)
|
||||
|
||||
# ━━━ Sonarr ━━━
|
||||
HOST1_SONARR_URL="http://localhost:8989"
|
||||
HOST1_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
|
||||
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
|
||||
|
||||
# Note: stand-up_comedy in both Sonarr + Radarr — TV specials and movie specials, one folder
|
||||
declare -A HOST1_SONARR_PATH_MAP=(
|
||||
["/tv"]="/mnt/user/Tv_Shows"
|
||||
["/ext-standup-comedy"]="/mnt/user/stand-up_comedy"
|
||||
["/kids tv"]="/mnt/user/Kids_Tv_Shows"
|
||||
["/ext-anime-shows"]="/mnt/user/Anime_Shows-Old"
|
||||
)
|
||||
|
||||
# ━━━ Radarr ━━━
|
||||
HOST1_RADARR_URL="http://localhost:7878"
|
||||
HOST1_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
|
||||
HOST1_TMDB_API_KEY="3dac5e2e49b5540472d2eafec4f01260"
|
||||
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
|
||||
|
||||
# Note: stand-up_comedy in both Radarr + Sonarr — movie specials and TV specials, one folder
|
||||
declare -A HOST1_RADARR_PATH_MAP=(
|
||||
["/movies"]="/mnt/user/Movies"
|
||||
["/kids movies"]="/mnt/user/Kids_Movies"
|
||||
["/ext-stand-up-comedy"]="/mnt/user/stand-up_comedy"
|
||||
["/anime-movies"]="/mnt/user/Anime_Movies-Old"
|
||||
)
|
||||
|
||||
# ━━━ Arr Recovery Toggles ━━━
|
||||
# false = skip that arr on this host — exits cleanly without error
|
||||
HOST1_SONARR_RECOVERY=true
|
||||
HOST1_RADARR_RECOVERY=true
|
||||
HOST1_LIDARR_RECOVERY=true # HOST1 only — exits cleanly on HOST2
|
||||
|
||||
# ==============================================================================================
|
||||
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Per-host check toggles and NIC config for system_watchdog.sh.
|
||||
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
|
||||
# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
|
||||
#
|
||||
# Three-tier response — all critical checks enabled by default on HOST1:
|
||||
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
|
||||
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
|
||||
# Tier 3 (standard strike system): everything else
|
||||
#
|
||||
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
|
||||
|
||||
# ━━━ Primary NIC ━━━
|
||||
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
|
||||
# Common values: eth0, bond0, br0, eno1
|
||||
HOST1_SYS_WATCHDOG_NIC="eth0"
|
||||
|
||||
# ━━━ Tier 1 — Critical Checks ━━━
|
||||
# These bypass the strike system — a single hit triggers immediate reboot.
|
||||
# Disabling any of these is not recommended — they protect against acute system failure.
|
||||
|
||||
# Docker daemon unresponsive → try restart, reboot if restart fails.
|
||||
# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
|
||||
HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
|
||||
|
||||
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
|
||||
# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.
|
||||
# Standard 95% threshold still uses strike system — only 99%+ is critical tier.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ROOTFS=true
|
||||
|
||||
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
|
||||
# A kernel oops means the kernel ran with a corrupted state — stability is not guaranteed.
|
||||
HOST1_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
|
||||
|
||||
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
|
||||
# At 95% FD: new connections fail, Docker can't spawn processes, SSH drops.
|
||||
HOST1_SYS_WATCHDOG_CHECK_FD=true
|
||||
|
||||
# /boot read-only detected → reboot immediately.
|
||||
# Unexpected read-only /boot means state files and config writes are silently failing.
|
||||
# Fallback state, watchdog reboot log, and lock files all go stale silently.
|
||||
HOST1_SYS_WATCHDOG_CHECK_BOOT=true
|
||||
|
||||
# ━━━ Tier 2 — Urgent OOM Check ━━━
|
||||
# Bypass strikes when RAM is critically low AND OOM kill rate confirms active crisis.
|
||||
# Both must be enabled for Tier 2 bypass to function — disable either to always use strikes.
|
||||
|
||||
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
|
||||
# Also provides diagnostic context in reboot messages (which processes were killed).
|
||||
HOST1_SYS_WATCHDOG_CHECK_OOM=true
|
||||
|
||||
# Free RAM check — required for both Tier 2 bypass and RAM tier logic.
|
||||
# Tiers: MEM_WARN_GB(10) → notify | MEM_SHUTDOWN_GB(6) → stop containers | MEM_GB(4) → strikes
|
||||
HOST1_SYS_WATCHDOG_CHECK_RAM=true
|
||||
|
||||
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
|
||||
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action is taken.
|
||||
# Single spikes are ignored — sustained problems trigger reboot.
|
||||
|
||||
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
|
||||
# Log spam (Docker log storms, syslog loops) fills rootfs — indicates something broken.
|
||||
HOST1_SYS_WATCHDOG_CHECK_LOG=true
|
||||
|
||||
# ZFS ARC memory pinned above SYS_WATCHDOG_ARC_PINNED_PCT after cache drop.
|
||||
# Enabled on HOST1 — ZFS cache pools actively used. Disable on hosts without ZFS.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ARC=true
|
||||
|
||||
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
|
||||
# Sustained high temp causes kernel throttling or panic. Requires lm-sensors.
|
||||
HOST1_SYS_WATCHDOG_CHECK_CPU_TEMP=true
|
||||
|
||||
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
|
||||
# DISABLED on HOST1 — Tdarr and Emby cause legitimate sustained load spikes during encoding.
|
||||
# Enable on idle servers or adjust SYS_WATCHDOG_LOAD_MULTIPLIER if load is always high.
|
||||
HOST1_SYS_WATCHDOG_CHECK_LOAD=false
|
||||
|
||||
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
|
||||
# Large zombie counts indicate serious process management failure — something is stuck.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ZOMBIES=true
|
||||
|
||||
# Check docker_watchdog.sh persistent skip list — required containers on skip list.
|
||||
# Cross-watchdog coordination: if docker_watchdog gave up, system_watchdog escalates.
|
||||
# ENABLED — HOST1 fully built and operational, skip list is meaningful.
|
||||
HOST1_SYS_WATCHDOG_CHECK_CONTAINERS=true
|
||||
|
||||
# /tmp filesystem usage above SYS_WATCHDOG_TMP_PCT with auto-clear attempt.
|
||||
# Script tries to clear aged /tmp files first — only strikes if clear fails.
|
||||
# Lock files, rsync temp files, and Docker ops use /tmp — 100% means lock failures.
|
||||
HOST1_SYS_WATCHDOG_CHECK_TMP=true
|
||||
|
||||
# Array disk error count delta in /proc/mdstat — accumulating errors = disk failing now.
|
||||
# Triggers on SYS_WATCHDOG_MDSTAT_ERROR_LIMIT new errors in one cycle.
|
||||
HOST1_SYS_WATCHDOG_CHECK_MDSTAT=true
|
||||
|
||||
# Primary NIC operstate — detects NIC going down (physical or driver failure).
|
||||
# Uses HOST1_SYS_WATCHDOG_NIC above. Strike system — brief flaps don't trigger reboot.
|
||||
HOST1_SYS_WATCHDOG_CHECK_NETWORK=true
|
||||
|
||||
# sshd running check — attempts restart before escalating.
|
||||
# sshd down = no remote access. Script tries rc.sshd start, notifies, strikes on failure.
|
||||
HOST1_SYS_WATCHDOG_CHECK_SSHD=true
|
||||
|
||||
# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
|
||||
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
|
||||
# Enable only if HOST1 has no CPU-intensive workloads.
|
||||
HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
|
||||
|
||||
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
|
||||
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
|
||||
HOST1_RW_PAUSE_CONTAINERS=(
|
||||
"Huntarr" # arr search automation — safe to suspend
|
||||
"Cleanuparr" # download cleanup — safe to suspend
|
||||
"Healarr" # arr health checks — safe to suspend
|
||||
"Soularr" # Slskd automation — background only
|
||||
"ChannelTube" # YouTube archiver — background only
|
||||
"Pinchflat" # YouTube archiver — background only
|
||||
)
|
||||
|
||||
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
|
||||
# Full stop — these are optional/heavy services that free significant RAM when stopped.
|
||||
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
|
||||
HOST1_RW_STOP_CONTAINERS=(
|
||||
"LocalAI" # GPU/CPU heavy — largest RAM consumer when idle
|
||||
"7DaysToDie" # game server — optional
|
||||
"V-Rising" # game server — optional
|
||||
"Code-Server" # IDE — not needed during pressure events
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
|
||||
# NPM: Admin email + password from NginxProxyManager settings.
|
||||
# lldap: Admin username + password from lldap config.
|
||||
# Authelia: Path to configuration.yml — same path both hosts (synced via critical-data rsync).
|
||||
|
||||
# ━━━ NginxProxyManager ━━━
|
||||
HOST1_NPM_URL="http://localhost:81"
|
||||
HOST1_NPM_USER="" # NPM admin email
|
||||
HOST1_NPM_PASS="" # NPM admin password
|
||||
|
||||
# ━━━ lldap ━━━
|
||||
HOST1_LLDAP_URL="http://localhost:17170"
|
||||
HOST1_LLDAP_USER="admin" # lldap admin username
|
||||
HOST1_LLDAP_PASS="" # lldap admin password
|
||||
|
||||
# ━━━ Authelia ━━━
|
||||
HOST1_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
|
||||
HOST1_AUTHELIA_CONTAINER="Authelia"
|
||||
|
||||
# ==============================================================================================
|
||||
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
@@ -0,0 +1,793 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ========================== HOST1 CONFIGURATION — unRAID-Gmer4Lfe ============================
|
||||
# ==============================================================================================
|
||||
# HOST1-specific variables — credentials, container names, share paths, failover lists.
|
||||
# Sourced after master.conf — values here extend shared profile arrays and add HOST1-specific
|
||||
# identity, credentials, and container configuration.
|
||||
#
|
||||
# Sparse checkout (git) ensures HOST2 never receives this file.
|
||||
# HOST2 never sees HOST1 credentials — clean separation at the file level.
|
||||
#
|
||||
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
|
||||
# DO NOT put HOST2 variables here — they belong in host2.conf.
|
||||
#
|
||||
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
|
||||
# IDENTITY hostname, SSH key
|
||||
# EMBY container name, URL, API key
|
||||
# NOTIFICATIONS Discord webhook
|
||||
# PARTNERSHIP auth containers, backup paths
|
||||
#
|
||||
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
|
||||
# DAILY SYNC SHARES media shares HOST1 owns and pushes to HOST2
|
||||
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
|
||||
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
|
||||
# BACKUP VERIFY shares for checksum verification against remote
|
||||
# HOST1 RSYNC PROFILE host1-appdata profile for HOST1-specific appdata syncs
|
||||
#
|
||||
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
|
||||
# DOCKER DAILY RESTART containers restarted daily
|
||||
# DOCKER WEEKLY RESTART containers restarted weekly
|
||||
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
|
||||
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
|
||||
#
|
||||
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
|
||||
# DDNS DDNS containers managed by HOST1
|
||||
# INTERNET LOSS containers stopped when internet is lost
|
||||
# FALLBACK TIERS what HOST1 runs for HOST2 per tier
|
||||
# TIER DELAYS how long HOST1 must be down before each tier activates on HOST2
|
||||
# RSYNC WRITEBACK HOST1 appdata synced back on handback
|
||||
#
|
||||
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
|
||||
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
|
||||
# MEDIA CLEANER folder lists for media_cleaner.sh
|
||||
#
|
||||
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
|
||||
# CERTIFICATE MONITOR domains checked for SSL expiry
|
||||
# SMART HEALTH drives to skip in SMART monitoring
|
||||
# ZFS REPORT pools to exclude from ZFS health report
|
||||
#
|
||||
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
|
||||
# TRANSCODES ramdisk size, thresholds, SSD path, server array
|
||||
#
|
||||
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
|
||||
# DOWNLOADERS slskd, SABnzbd, qBittorrent credentials and URLs
|
||||
# LIDARR URL, API key, path map
|
||||
# SONARR URL, API key, path map
|
||||
# RADARR URL, API key, path map
|
||||
# ARR RECOVERY per-arr recovery toggles
|
||||
#
|
||||
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
|
||||
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
|
||||
#
|
||||
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
|
||||
# RESOURCE MANAGER containers paused/stopped under memory pressure
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
# ==============================================================================================
|
||||
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Identity ━━━
|
||||
# HOST1 hostname lives in master.conf (not a credential — safe for all servers).
|
||||
# SSH key used for all server-to-server operations — rsync, failover container commands.
|
||||
# Must be in /root/.ssh/ and authorised in HOST2's /root/.ssh/authorized_keys.
|
||||
HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation"
|
||||
HOST1_OWNER="gmer4lfe"
|
||||
HOST1_OWNER_EMAIL="gmer4lfe@gmail.com"
|
||||
HOST1_UNRAID_API_KEY="e6acb2dfea4c4434fc63ff35f78b4b68434680cc68b602e0eb48059b5f5ef4b9"
|
||||
|
||||
# ━━━ Emby ━━━
|
||||
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
|
||||
# weekly_sync_maintenance.sh, and HOST1_TRANSCODE_SERVERS below.
|
||||
# API key: Emby Dashboard → API Keys → + New Key
|
||||
HOST1_EMBY_CONTAINER="Emby"
|
||||
HOST1_EMBY_URL="http://localhost:8096"
|
||||
HOST1_EMBY_API_KEY="0c27448d93a7431f9ac63569f7655829"
|
||||
|
||||
# ━━━ Jellyfin ━━━
|
||||
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
|
||||
HOST1_JELLYFIN_CONTAINER="Jellyfin"
|
||||
HOST1_JELLYFIN_URL="http://localhost:8095"
|
||||
HOST1_JELLYFIN_API_KEY="4e820e7df74c4933acec212b1996314e"
|
||||
|
||||
# ━━━ Gitea ━━━
|
||||
# Personal access token for gitea_ssh_setup.sh — registers this server's SSH public key
|
||||
# with Gitea so git operations use key auth instead of passwords.
|
||||
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user
|
||||
HOST1_GITEA_API_TOKEN=""
|
||||
|
||||
# ━━━ Notifications ━━━
|
||||
# Discord webhook — leave blank to disable.
|
||||
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
|
||||
HOST1_DISCORD_WEBHOOK=""
|
||||
|
||||
# ━━━ Partnership ━━━
|
||||
# HOST1 is always the owner (source of truth) unless --transfer has been run.
|
||||
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
|
||||
|
||||
# Auth containers reconfigured on onboard/offboard.
|
||||
# Format: "ContainerName|WebUIPort"
|
||||
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
|
||||
# On offboard → WebUI pointed back at localhost
|
||||
HOST1_PARTNERSHIP_AUTH_WEBUIS=(
|
||||
"NginxProxyManager|81"
|
||||
"Lldap-Gmer4Lfe|17170"
|
||||
"Authelia|9091"
|
||||
"Authelia-Secondary|9092"
|
||||
)
|
||||
|
||||
# XML templates (from this server's templates-user/) pushed to mirror during onboard.
|
||||
# These become the mirror's active auth stack, backed by the rsync-synced appdata.
|
||||
# Update filename if Lldap is renamed to drop the host suffix.
|
||||
HOST1_PARTNERSHIP_AUTH_STACK=(
|
||||
# Dependencies first — Mariadb/Redis must be healthy before Authelia starts
|
||||
"my-Mariadb-Authelia.xml"
|
||||
"my-Mariadb-Authelia-Secondary.xml"
|
||||
"my-Redis-Authelia.xml"
|
||||
"my-Redis-Authelia-Secondary.xml"
|
||||
# Auth apps — deployed after their deps are confirmed healthy
|
||||
"my-Authelia.xml"
|
||||
"my-Authelia-Secondary.xml"
|
||||
"my-NginxProxyManager.xml"
|
||||
"my-Lldap-Gmer4Lfe.xml"
|
||||
# Source of truth — must be available on HOST2 independently of the auth stack
|
||||
"my-Gitea.xml"
|
||||
)
|
||||
|
||||
# XML templates pushed to mirror for the arr stack during onboard.
|
||||
# Deps (e.g. databases) first if any — same ordering rule as auth stack.
|
||||
HOST1_PARTNERSHIP_ARR_STACK=(
|
||||
# "my-Sonarr.xml"
|
||||
# "my-Radarr.xml"
|
||||
# "my-Lidarr.xml"
|
||||
# "my-Prowlarr.xml"
|
||||
# "my-Bazarr.xml"
|
||||
)
|
||||
|
||||
# Paths HOST2 should collect during the grace window after offboard.
|
||||
# Notified on offboard — no auto-deletion, HOST2 must collect manually within PARTNERSHIP_GRACE_HOURS.
|
||||
HOST1_PARTNERSHIP_MIRROR_BACKUPS=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
|
||||
)
|
||||
|
||||
# Containers parked on this server when partnership is active.
|
||||
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
|
||||
HOST1_PARTNERSHIP_OWN_CONTAINERS=(
|
||||
# "Emby"
|
||||
# "NginxProxyManager"
|
||||
)
|
||||
|
||||
# Emby admin provisioning — toggle is owner-only, credentials are per-host.
|
||||
# Owner enables/disables the feature. Each host sets the account they want on the shared Emby.
|
||||
# On onboard: owner reads mirror's HOST*_PARTNERSHIP_EMBY_ADMIN_* and creates that account.
|
||||
# On offboard: account is deleted. Username collision → onboard exits with error.
|
||||
HOST1_PARTNERSHIP_PROVISION_EMBY_ADMIN=false # owner controls whether Emby is shared
|
||||
HOST1_PARTNERSHIP_EMBY_PORT=8096
|
||||
HOST1_PARTNERSHIP_EMBY_ADMIN_USER="" # this server's desired Emby username
|
||||
HOST1_PARTNERSHIP_EMBY_ADMIN_PASS="" # this server's desired Emby password
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Daily Sync Shares ━━━
|
||||
# Shares HOST1 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
|
||||
# Mesh model: every node pushes every media share — no ownership, no mirrors.
|
||||
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
|
||||
# arr_cleanup removes true orphans based on local arr state.
|
||||
# Any node can download content to any share — it propagates to all nodes on the next cycle.
|
||||
# Nextcloud is intentionally one-directional (HOST1→HOST2 offsite backup — not arr-managed).
|
||||
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
|
||||
# For shares needing container stops or custom options — add a profile in master.conf.
|
||||
HOST1_DAILY_SYNC_SHARES=(
|
||||
/mnt/user/Books
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/Nextcloud
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Sports
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
# Personal encrypted shares — synced for offsite backup, independent of media shares.
|
||||
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
|
||||
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
|
||||
HOST1_PERSONAL_SHARES=(
|
||||
# /mnt/user/HOST1-Personal # uncomment after creating encrypted dataset
|
||||
)
|
||||
|
||||
# ━━━ Weekly Sync Shares ━━━
|
||||
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
|
||||
# Containers stopped both sides before sync — full clean state guaranteed.
|
||||
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
|
||||
# Order matters — Emby first (larger transfer), then Critical-Data (auth stack).
|
||||
HOST1_WEEKLY_SYNC_SHARES=(
|
||||
"/mnt/user/Media_Server/Emby" # emby profile — full clean mirror
|
||||
"/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile — auth stack
|
||||
)
|
||||
|
||||
# ━━━ Intermediate Sync Shares ━━━
|
||||
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
|
||||
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
|
||||
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
|
||||
HOST1_INTERMEDIATE_SYNC_SHARES=(
|
||||
# Add shares here to enable mid-day rsync
|
||||
# Example: "/mnt/user/Emby_Metadata"
|
||||
)
|
||||
|
||||
# ━━━ Critical Sync Shares ━━━
|
||||
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
|
||||
# Format: "/path/to/share" or "/path/to/share|profile-name"
|
||||
# Order matters — Critical-Data first (auth stack), then Emby dirty sync.
|
||||
HOST1_CRITICAL_SYNC_SHARES=(
|
||||
"/mnt/user/appdata-Fallback/Critical-Data|critical-fallback" # auth dirty sync — stays running
|
||||
"/mnt/user/Media_Server/Emby|emby-fallback" # Emby dirty sync — stays running
|
||||
)
|
||||
|
||||
# ━━━ Backup Verify ━━━
|
||||
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
|
||||
# Leave empty to use HOST1_DAILY_SYNC_SHARES automatically.
|
||||
# Sample size and minimum file size defined in master.conf.
|
||||
HOST1_BACKUP_VERIFY_SHARES=(
|
||||
# leave empty to use HOST1_DAILY_SYNC_SHARES automatically
|
||||
)
|
||||
|
||||
# ━━━ HOST1 Rsync Profile — host1-appdata ━━━
|
||||
# HOST1-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
|
||||
# Use for appdata unique to HOST1 (Organizrv2, VaultWarden, UptimeKuma etc.)
|
||||
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
|
||||
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST1-Appdata --profile=host1-appdata
|
||||
PROFILE_RSYNC_OPTS[host1-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host1-appdata]:-8000}"
|
||||
PROFILE_BW_LIMIT[host1-appdata]=8000
|
||||
PROFILE_RETRY_COUNT[host1-appdata]=3
|
||||
PROFILE_SLEEP[host1-appdata]=300
|
||||
PROFILE_CRITICAL_CONTAINER_NAMES[host1-appdata]="Organizrv2-Gmer4Lfe UptimeKuma-Gmer4Lfe VaultWarden-Gmer4Lfe"
|
||||
PROFILE_DELAYED_CONTAINERS[host1-appdata]=""
|
||||
PROFILE_CONTAINER_DELAY[host1-appdata]=5
|
||||
PROFILE_EXCLUDE_DIRS[host1-appdata]="logs *.tmp"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Docker Daily Restart ━━━
|
||||
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
|
||||
# Dispatcharr degrades over time without restart — daily is intentional, not just housekeeping.
|
||||
# Order matters — auth stack first, then media services.
|
||||
HOST1_DAILY_RESTART_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Lldap-Gmer4Lfe"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
"Dispatcharr-Iptv-Users"
|
||||
"Dispatcharr" # Live TV scheduler — degrades without daily restart
|
||||
"Dispatcharr-Basic"
|
||||
"ErsatzTV-Emby"
|
||||
"Slskd" # Soulseek connection drops after extended uptime; restart refreshes share index
|
||||
)
|
||||
|
||||
# ━━━ Docker Weekly Restart ━━━
|
||||
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
|
||||
# Containers already stopped for weekly sync — restart adds zero extra downtime.
|
||||
HOST1_WEEKLY_RESTART_CONTAINERS=(
|
||||
"NextCloud"
|
||||
"Organizrv2-Gmer4Lfe"
|
||||
"AdGuard-Home"
|
||||
"Immich-Gmer4Lfe"
|
||||
)
|
||||
|
||||
# ━━━ Docker Watchdog ━━━
|
||||
# Per-HOST1 container configuration for docker_watchdog.sh.
|
||||
# Shared thresholds and toggles live in master.conf.
|
||||
|
||||
# Memory hard limits in MB — immediate restart if exceeded.
|
||||
# Set at "container is clearly broken" not "container is busy".
|
||||
# 20GB=20480 18GB=18432 16GB=16384 12GB=12288 8GB=8192 4GB=4096 2GB=2048 1GB=1024
|
||||
declare -A HOST1_WATCHDOG_CONTAINERS=(
|
||||
["Emby"]=20480 # 20GB — large library + active transcodes
|
||||
["LidaTube"]=6144 # 6GB — memory leak over time
|
||||
["Tdarr"]=6144 # 6GB — encoding is memory intensive
|
||||
["Code-Server"]=1024 # 1GB — should never need more
|
||||
)
|
||||
|
||||
# HTTP health check URLs — checked every cycle, strike system before restart.
|
||||
# Only add containers with a meaningful web interface to check.
|
||||
declare -A HOST1_WATCHDOG_CONTAINER_URLS=(
|
||||
["Emby"]="http://localhost:8096"
|
||||
)
|
||||
|
||||
# Required containers — must always be running on HOST1.
|
||||
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
|
||||
# Listed in dependency order — dependencies before dependents.
|
||||
HOST1_WATCHDOG_REQUIRED_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Lldap-Gmer4Lfe"
|
||||
"Mariadb-Authelia"
|
||||
"Mariadb-Authelia-Secondary"
|
||||
"Redis-Authelia"
|
||||
"Redis-Authelia-Secondary"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
)
|
||||
|
||||
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
|
||||
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
|
||||
HOST1_WATCHDOG_SCAN_IGNORE=(
|
||||
"DashGate"
|
||||
"PIA-WG-Config-Generator"
|
||||
"Aperture"
|
||||
"Aperture-Kids"
|
||||
"pgvector-18-Apeture-Kids"
|
||||
"Pgvector18-Aperture"
|
||||
"emby-test" # broken test container (exit 127 — bad image)
|
||||
)
|
||||
|
||||
# Dependency ordering — skip restarting a container if its dependency is also down.
|
||||
# Prevents watchdog from restarting Authelia before Mariadb is back up.
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime.
|
||||
declare -A HOST1_WATCHDOG_DEPENDENCIES=(
|
||||
["Authelia"]="Mariadb-Authelia Redis-Authelia"
|
||||
["Authelia-Secondary"]="Mariadb-Authelia Redis-Authelia-Secondary"
|
||||
["NextCloud"]="Postgres-NextCloud"
|
||||
)
|
||||
|
||||
# Per-container appdata growth suppress ceilings in MB.
|
||||
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
|
||||
# Use this when a container legitimately has large stable data and you want to guarantee
|
||||
# it never triggers a false-positive growth alert. Growth warnings are suppressed while the
|
||||
# container's dir stays below this ceiling; above it, warnings resume as normal.
|
||||
# 50GB=51200 25GB=25600 20GB=20480 15GB=15360 10GB=10240 5GB=5120
|
||||
declare -A HOST1_WATCHDOG_APPDATA_SIZES=(
|
||||
["Tdarr"]="25600" # 25GB — transcode cache grows legitimately during active jobs
|
||||
["7dtd"]="20480" # 20GB — game server world data, expected to be large
|
||||
)
|
||||
|
||||
# ━━━ Network Watchdog ━━━
|
||||
# Host-specific connectivity config for Watchdogs/System/network_watchdog.sh.
|
||||
HOST1_NETWORK_WATCHDOG_DDNS_DOMAIN="gmer4lfe.com"
|
||||
HOST1_NETWORK_WATCHDOG_DDNS_CONTAINER="Gmer4Lfe.com"
|
||||
HOST1_NETWORK_WATCHDOG_NPM_URL="https://gmer4lfe.com"
|
||||
|
||||
# ━━━ Docker Network Connect ━━━
|
||||
# Containers connected to custom networks at array start by docker_network_connect.sh.
|
||||
# Networks created if they don't exist — idempotent, safe to re-run.
|
||||
HOST1_NETWORK_CONNECT_CONTAINERS=(
|
||||
"memcached"
|
||||
"Npm-CrowdSec"
|
||||
)
|
||||
|
||||
HOST1_NETWORK_CONNECT_NETWORKS=(
|
||||
"high-availability"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ DDNS ━━━
|
||||
# DDNS containers HOST1 manages — started/stopped by fallback.sh per DDNS absolute rules:
|
||||
# Internet loss → stop immediately
|
||||
# Failover → HOST2 starts HOST1's DDNS as Tier 1 (before any other containers)
|
||||
# Handback → stop HOST1's DDNS on HOST2 → rsync → start containers → start local DDNS last
|
||||
HOST1_DDNS_CONTAINERS=(
|
||||
"Gmer4Lfe.com"
|
||||
)
|
||||
|
||||
# ━━━ Internet Loss ━━━
|
||||
# Containers stopped immediately on HOST1 when internet connection is lost.
|
||||
# Prevents external-facing services from operating without connectivity.
|
||||
FALLBACK_HOST1_STOP_ON_NO_NET=(
|
||||
"Gmer4Lfe.com"
|
||||
)
|
||||
|
||||
# ━━━ Fallback Tiers — HOST1 Runs for HOST2 ━━━
|
||||
# Containers HOST1 starts when HOST2 goes down.
|
||||
# Tier 1 is always immediate — vital services cannot wait.
|
||||
# Higher tiers activate after HOST2_TIER*_DELAY minutes (set in host2.conf).
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER1=(
|
||||
"Gmer4Lfe.us"
|
||||
"VaultWarden-Jayred365"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER2=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER3=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER4=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
# ━━━ Tier Delays — HOST1's Containers on HOST2 ━━━
|
||||
# How long HOST1 must be down before each tier activates on HOST2 — in minutes.
|
||||
# Tier 1 is always immediate — no delay var needed.
|
||||
HOST1_TIER2_DELAY=240 # 4 hours — NextCloud, Immich
|
||||
HOST1_TIER3_DELAY=720 # 12 hours — secondary services
|
||||
HOST1_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
|
||||
|
||||
# ━━━ Rsync Writeback — HOST1 Appdata Back on Handback ━━━
|
||||
# Syncs HOST1 appdata BACK to HOST1 when it comes back online after a failover.
|
||||
# Containers stopped before writeback — clean source, no competing writes.
|
||||
#
|
||||
# HOST1_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
|
||||
# is more reliable than dirty sync data for brief outages.
|
||||
HOST1_TIER1_WRITEBACK_DELAY=60 # skip Emby writeback if outage under 1hr
|
||||
|
||||
# Tier 4 automatically syncs HOST1_DAILY_SYNC_SHARES — only list paths NOT in that array.
|
||||
FALLBACK_HOST1_WRITEBACK_TIER1=(
|
||||
"/mnt/user/Media_Server/Emby" # watch states built up during outage
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER2=(
|
||||
"/mnt/user/appdata-Fallback/Important-Data" # NextCloud + Postgres — files added during outage
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER3=(
|
||||
# "location-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER4=(
|
||||
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Media Permissions ━━━
|
||||
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
|
||||
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
|
||||
HOST1_MEDIA_PERMISSION_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/appcache
|
||||
/mnt/user/Books
|
||||
/mnt/user/Downloads
|
||||
/mnt/user/Games
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movie_Recordings
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/Photo
|
||||
/mnt/user/Sports
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Temp_Storage
|
||||
/mnt/user/Tv_Recordings
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/YouTube
|
||||
)
|
||||
|
||||
# ━━━ Media Cleaner ━━━
|
||||
# Folder lists for media_cleaner.sh — two profiles: anime and media.
|
||||
# File patterns shared across all servers — defined in master.conf.
|
||||
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
|
||||
HOST1_ANIME_CLEAN_FOLDERS=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
)
|
||||
|
||||
HOST1_MEDIA_CLEAN_FOLDERS=(
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Sports
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Tv_Shows
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Certificate Monitor ━━━
|
||||
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
|
||||
# Checks the actual certificate served, not what NPM thinks it has.
|
||||
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
|
||||
HOST1_CERT_MONITOR_DOMAINS=(
|
||||
"Gmer4Lfe.com"
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ SMART Health ━━━
|
||||
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
|
||||
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
|
||||
HOST1_SMART_IGNORE_DRIVES=(
|
||||
"sda" # boot USB — SMART not meaningful on flash drives
|
||||
)
|
||||
|
||||
# ━━━ ZFS Report ━━━
|
||||
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
|
||||
# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
|
||||
# Pool health thresholds defined in master.conf.
|
||||
HOST1_ZFS_REPORT_IGNORE_POOLS=(
|
||||
"disk5"
|
||||
"disk6"
|
||||
"disk8"
|
||||
"disk9"
|
||||
"disk10"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
|
||||
# Real-world: 9 streams peaked at ~5.5GB — 10G gives generous headroom on 128GB RAM.
|
||||
HOST1_RAMDISK_SIZE="10G"
|
||||
|
||||
# Usage thresholds — coupled to HOST1_RAMDISK_SIZE, adjust all three together if size changes.
|
||||
# Hysteresis gap (8.5 - 7 = 1.5GB) prevents flip-flop between ramdisk and SSD.
|
||||
HOST1_RAMDISK_WARN_GB=8.5 # flip to SSD when ramdisk usage reaches this
|
||||
HOST1_RAMDISK_LOW_GB=7 # flip back to ramdisk when usage drops to this
|
||||
|
||||
# SSD fallback path — where transcodes land when ramdisk exceeds HOST1_RAMDISK_WARN_GB.
|
||||
# Must be on cache pool — array disks too slow for active transcode writes.
|
||||
HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
|
||||
|
||||
# Media servers sharing the ramdisk transcode space on HOST1.
|
||||
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
|
||||
# Entries with placeholder API keys are skipped automatically.
|
||||
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
|
||||
HOST1_TRANSCODE_SERVERS=(
|
||||
"${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
|
||||
"${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
|
||||
# detect_hosts() selects HOST1 vars when running on HOST1.
|
||||
#
|
||||
# PATH MAPS — container path → host path translation.
|
||||
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
|
||||
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
|
||||
|
||||
# ━━━ Downloaders ━━━
|
||||
# Used by downloaders_reset.sh — runs every 30min via CRITICAL_MAINTENANCE_SCRIPTS.
|
||||
# Clears stuck states, purges old history, prepares each client for a clean cycle.
|
||||
|
||||
# slskd — clears stuck searches, dead transfers, purges expired failed imports.
|
||||
# SLSKD_FAILED_IMPORTS_DIR: where Soularr moves albums Lidarr rejected.
|
||||
HOST1_SLSKD_URL="http://localhost:8980"
|
||||
HOST1_SLSKD_API_KEY="4bF9kL2mNpQrT7vWxYz1A3dEgHjKoRsU"
|
||||
HOST1_SLSKD_FAILED_IMPORTS_DIR="/mnt/user/Temp_Storage/Slskd/completed/failed_imports"
|
||||
|
||||
# SABnzbd
|
||||
HOST1_SABNZBD_URL="http://localhost:8180"
|
||||
HOST1_SABNZBD_API_KEY="8bfefe41d83b4d50883e32859b55ca9a"
|
||||
|
||||
# qBittorrent — deleteFiles=false removes torrent from qBit but leaves files on disk.
|
||||
# Radarr/Sonarr manage actual files independently.
|
||||
HOST1_QBIT_URL="http://localhost:8080"
|
||||
HOST1_QBIT_USERNAME="root"
|
||||
HOST1_QBIT_PASSWORD="Stay0utD!ck"
|
||||
|
||||
# ━━━ Lidarr — HOST1 only ━━━
|
||||
# HOST2 does not run Lidarr — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
|
||||
HOST1_LIDARR_URL="http://localhost:8686"
|
||||
HOST1_LIDARR_API_KEY="b2977e71ef074bc0a0529d9fcce3b2dc"
|
||||
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music-New"
|
||||
HOST1_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
|
||||
HOST1_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
|
||||
|
||||
declare -A HOST1_LIDARR_PATH_MAP=(
|
||||
["/ext-music"]="/mnt/user/Music-New"
|
||||
)
|
||||
|
||||
# ━━━ Sonarr ━━━
|
||||
HOST1_SONARR_URL="http://localhost:8989"
|
||||
HOST1_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
|
||||
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
|
||||
|
||||
# Note: stand-up_comedy in both Sonarr + Radarr — TV specials and movie specials, one folder
|
||||
declare -A HOST1_SONARR_PATH_MAP=(
|
||||
["/tv"]="/mnt/user/Tv_Shows"
|
||||
["/ext-standup-comedy"]="/mnt/user/stand-up_comedy"
|
||||
["/kids tv"]="/mnt/user/Kids_Tv_Shows"
|
||||
["/ext-anime-shows"]="/mnt/user/Anime_Shows-Old"
|
||||
)
|
||||
|
||||
# ━━━ Radarr ━━━
|
||||
HOST1_RADARR_URL="http://localhost:7878"
|
||||
HOST1_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
|
||||
HOST1_TMDB_API_KEY="3dac5e2e49b5540472d2eafec4f01260"
|
||||
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
|
||||
|
||||
# Note: stand-up_comedy in both Radarr + Sonarr — movie specials and TV specials, one folder
|
||||
declare -A HOST1_RADARR_PATH_MAP=(
|
||||
["/movies"]="/mnt/user/Movies"
|
||||
["/kids movies"]="/mnt/user/Kids_Movies"
|
||||
["/ext-stand-up-comedy"]="/mnt/user/stand-up_comedy"
|
||||
["/anime-movies"]="/mnt/user/Anime_Movies-Old"
|
||||
)
|
||||
|
||||
# ━━━ Arr Recovery Toggles ━━━
|
||||
# false = skip that arr on this host — exits cleanly without error
|
||||
HOST1_SONARR_RECOVERY=true
|
||||
HOST1_RADARR_RECOVERY=true
|
||||
HOST1_LIDARR_RECOVERY=true # HOST1 only — exits cleanly on HOST2
|
||||
|
||||
# ==============================================================================================
|
||||
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Per-host check toggles and NIC config for system_watchdog.sh.
|
||||
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
|
||||
# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
|
||||
#
|
||||
# Three-tier response — all critical checks enabled by default on HOST1:
|
||||
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
|
||||
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
|
||||
# Tier 3 (standard strike system): everything else
|
||||
#
|
||||
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
|
||||
|
||||
# ━━━ Primary NIC ━━━
|
||||
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
|
||||
# Common values: eth0, bond0, br0, eno1
|
||||
HOST1_SYS_WATCHDOG_NIC="eth0"
|
||||
|
||||
# ━━━ Tier 1 — Critical Checks ━━━
|
||||
# These bypass the strike system — a single hit triggers immediate reboot.
|
||||
# Disabling any of these is not recommended — they protect against acute system failure.
|
||||
|
||||
# Docker daemon unresponsive → try restart, reboot if restart fails.
|
||||
# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
|
||||
HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
|
||||
|
||||
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
|
||||
# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.
|
||||
# Standard 95% threshold still uses strike system — only 99%+ is critical tier.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ROOTFS=true
|
||||
|
||||
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
|
||||
# A kernel oops means the kernel ran with a corrupted state — stability is not guaranteed.
|
||||
HOST1_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
|
||||
|
||||
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
|
||||
# At 95% FD: new connections fail, Docker can't spawn processes, SSH drops.
|
||||
HOST1_SYS_WATCHDOG_CHECK_FD=true
|
||||
|
||||
# /boot read-only detected → reboot immediately.
|
||||
# Unexpected read-only /boot means state files and config writes are silently failing.
|
||||
# Fallback state, watchdog reboot log, and lock files all go stale silently.
|
||||
HOST1_SYS_WATCHDOG_CHECK_BOOT=true
|
||||
|
||||
# ━━━ Tier 2 — Urgent OOM Check ━━━
|
||||
# Bypass strikes when RAM is critically low AND OOM kill rate confirms active crisis.
|
||||
# Both must be enabled for Tier 2 bypass to function — disable either to always use strikes.
|
||||
|
||||
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
|
||||
# Also provides diagnostic context in reboot messages (which processes were killed).
|
||||
HOST1_SYS_WATCHDOG_CHECK_OOM=true
|
||||
|
||||
# Free RAM check — required for both Tier 2 bypass and RAM tier logic.
|
||||
# Tiers: MEM_WARN_GB(10) → notify | MEM_SHUTDOWN_GB(6) → stop containers | MEM_GB(4) → strikes
|
||||
HOST1_SYS_WATCHDOG_CHECK_RAM=true
|
||||
|
||||
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
|
||||
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action is taken.
|
||||
# Single spikes are ignored — sustained problems trigger reboot.
|
||||
|
||||
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
|
||||
# Log spam (Docker log storms, syslog loops) fills rootfs — indicates something broken.
|
||||
HOST1_SYS_WATCHDOG_CHECK_LOG=true
|
||||
|
||||
# ZFS ARC memory pinned above SYS_WATCHDOG_ARC_PINNED_PCT after cache drop.
|
||||
# Enabled on HOST1 — ZFS cache pools actively used. Disable on hosts without ZFS.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ARC=true
|
||||
|
||||
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
|
||||
# Sustained high temp causes kernel throttling or panic. Requires lm-sensors.
|
||||
HOST1_SYS_WATCHDOG_CHECK_CPU_TEMP=true
|
||||
|
||||
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
|
||||
# DISABLED on HOST1 — Tdarr and Emby cause legitimate sustained load spikes during encoding.
|
||||
# Enable on idle servers or adjust SYS_WATCHDOG_LOAD_MULTIPLIER if load is always high.
|
||||
HOST1_SYS_WATCHDOG_CHECK_LOAD=false
|
||||
|
||||
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
|
||||
# Large zombie counts indicate serious process management failure — something is stuck.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ZOMBIES=true
|
||||
|
||||
# Check docker_watchdog.sh persistent skip list — required containers on skip list.
|
||||
# Cross-watchdog coordination: if docker_watchdog gave up, system_watchdog escalates.
|
||||
# ENABLED — HOST1 fully built and operational, skip list is meaningful.
|
||||
HOST1_SYS_WATCHDOG_CHECK_CONTAINERS=true
|
||||
|
||||
# /tmp filesystem usage above SYS_WATCHDOG_TMP_PCT with auto-clear attempt.
|
||||
# Script tries to clear aged /tmp files first — only strikes if clear fails.
|
||||
# Lock files, rsync temp files, and Docker ops use /tmp — 100% means lock failures.
|
||||
HOST1_SYS_WATCHDOG_CHECK_TMP=true
|
||||
|
||||
# Array disk error count delta in /proc/mdstat — accumulating errors = disk failing now.
|
||||
# Triggers on SYS_WATCHDOG_MDSTAT_ERROR_LIMIT new errors in one cycle.
|
||||
HOST1_SYS_WATCHDOG_CHECK_MDSTAT=true
|
||||
|
||||
# Primary NIC operstate — detects NIC going down (physical or driver failure).
|
||||
# Uses HOST1_SYS_WATCHDOG_NIC above. Strike system — brief flaps don't trigger reboot.
|
||||
HOST1_SYS_WATCHDOG_CHECK_NETWORK=true
|
||||
|
||||
# sshd running check — attempts restart before escalating.
|
||||
# sshd down = no remote access. Script tries rc.sshd start, notifies, strikes on failure.
|
||||
HOST1_SYS_WATCHDOG_CHECK_SSHD=true
|
||||
|
||||
# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
|
||||
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
|
||||
# Enable only if HOST1 has no CPU-intensive workloads.
|
||||
HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
|
||||
|
||||
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
|
||||
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
|
||||
HOST1_RW_PAUSE_CONTAINERS=(
|
||||
"Huntarr" # arr search automation — safe to suspend
|
||||
"Cleanuparr" # download cleanup — safe to suspend
|
||||
"Healarr" # arr health checks — safe to suspend
|
||||
"Soularr" # Slskd automation — background only
|
||||
"ChannelTube" # YouTube archiver — background only
|
||||
"Pinchflat" # YouTube archiver — background only
|
||||
)
|
||||
|
||||
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
|
||||
# Full stop — these are optional/heavy services that free significant RAM when stopped.
|
||||
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
|
||||
HOST1_RW_STOP_CONTAINERS=(
|
||||
"LocalAI" # GPU/CPU heavy — largest RAM consumer when idle
|
||||
"7DaysToDie" # game server — optional
|
||||
"V-Rising" # game server — optional
|
||||
"Code-Server" # IDE — not needed during pressure events
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
|
||||
# NPM: Admin email + password from NginxProxyManager settings.
|
||||
# lldap: Admin username + password from lldap config.
|
||||
# Authelia: Path to configuration.yml — same path both hosts (synced via critical-data rsync).
|
||||
|
||||
# ━━━ NginxProxyManager ━━━
|
||||
HOST1_NPM_URL="http://localhost:81"
|
||||
HOST1_NPM_USER="" # NPM admin email
|
||||
HOST1_NPM_PASS="" # NPM admin password
|
||||
|
||||
# ━━━ lldap ━━━
|
||||
HOST1_LLDAP_URL="http://localhost:17170"
|
||||
HOST1_LLDAP_USER="admin" # lldap admin username
|
||||
HOST1_LLDAP_PASS="" # lldap admin password
|
||||
|
||||
# ━━━ Authelia ━━━
|
||||
HOST1_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
|
||||
HOST1_AUTHELIA_CONTAINER="Authelia"
|
||||
|
||||
# ==============================================================================================
|
||||
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
@@ -0,0 +1,793 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ========================== HOST1 CONFIGURATION — unRAID-Gmer4Lfe ============================
|
||||
# ==============================================================================================
|
||||
# HOST1-specific variables — credentials, container names, share paths, failover lists.
|
||||
# Sourced after master.conf — values here extend shared profile arrays and add HOST1-specific
|
||||
# identity, credentials, and container configuration.
|
||||
#
|
||||
# Sparse checkout (git) ensures HOST2 never receives this file.
|
||||
# HOST2 never sees HOST1 credentials — clean separation at the file level.
|
||||
#
|
||||
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
|
||||
# DO NOT put HOST2 variables here — they belong in host2.conf.
|
||||
#
|
||||
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
|
||||
# IDENTITY hostname, SSH key
|
||||
# EMBY container name, URL, API key
|
||||
# NOTIFICATIONS Discord webhook
|
||||
# PARTNERSHIP auth containers, backup paths
|
||||
#
|
||||
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
|
||||
# DAILY SYNC SHARES media shares HOST1 owns and pushes to HOST2
|
||||
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
|
||||
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
|
||||
# BACKUP VERIFY shares for checksum verification against remote
|
||||
# HOST1 RSYNC PROFILE host1-appdata profile for HOST1-specific appdata syncs
|
||||
#
|
||||
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
|
||||
# DOCKER DAILY RESTART containers restarted daily
|
||||
# DOCKER WEEKLY RESTART containers restarted weekly
|
||||
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
|
||||
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
|
||||
#
|
||||
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
|
||||
# DDNS DDNS containers managed by HOST1
|
||||
# INTERNET LOSS containers stopped when internet is lost
|
||||
# FALLBACK TIERS what HOST1 runs for HOST2 per tier
|
||||
# TIER DELAYS how long HOST1 must be down before each tier activates on HOST2
|
||||
# RSYNC WRITEBACK HOST1 appdata synced back on handback
|
||||
#
|
||||
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
|
||||
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
|
||||
# MEDIA CLEANER folder lists for media_cleaner.sh
|
||||
#
|
||||
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
|
||||
# CERTIFICATE MONITOR domains checked for SSL expiry
|
||||
# SMART HEALTH drives to skip in SMART monitoring
|
||||
# ZFS REPORT pools to exclude from ZFS health report
|
||||
#
|
||||
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
|
||||
# TRANSCODES ramdisk size, thresholds, SSD path, server array
|
||||
#
|
||||
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
|
||||
# DOWNLOADERS slskd, SABnzbd, qBittorrent credentials and URLs
|
||||
# LIDARR URL, API key, path map
|
||||
# SONARR URL, API key, path map
|
||||
# RADARR URL, API key, path map
|
||||
# ARR RECOVERY per-arr recovery toggles
|
||||
#
|
||||
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
|
||||
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
|
||||
#
|
||||
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
|
||||
# RESOURCE MANAGER containers paused/stopped under memory pressure
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
# ==============================================================================================
|
||||
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Identity ━━━
|
||||
# HOST1 hostname lives in master.conf (not a credential — safe for all servers).
|
||||
# SSH key used for all server-to-server operations — rsync, failover container commands.
|
||||
# Must be in /root/.ssh/ and authorised in HOST2's /root/.ssh/authorized_keys.
|
||||
HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation"
|
||||
HOST1_OWNER="gmer4lfe"
|
||||
HOST1_OWNER_EMAIL="gmer4lfe@gmail.com"
|
||||
HOST1_UNRAID_API_KEY="e6acb2dfea4c4434fc63ff35f78b4b68434680cc68b602e0eb48059b5f5ef4b9"
|
||||
|
||||
# ━━━ Emby ━━━
|
||||
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
|
||||
# weekly_sync_maintenance.sh, and HOST1_TRANSCODE_SERVERS below.
|
||||
# API key: Emby Dashboard → API Keys → + New Key
|
||||
HOST1_EMBY_CONTAINER="Emby"
|
||||
HOST1_EMBY_URL="http://localhost:8096"
|
||||
HOST1_EMBY_API_KEY="0c27448d93a7431f9ac63569f7655829"
|
||||
|
||||
# ━━━ Jellyfin ━━━
|
||||
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
|
||||
HOST1_JELLYFIN_CONTAINER="Jellyfin"
|
||||
HOST1_JELLYFIN_URL="http://localhost:8095"
|
||||
HOST1_JELLYFIN_API_KEY="4e820e7df74c4933acec212b1996314e"
|
||||
|
||||
# ━━━ Gitea ━━━
|
||||
# Personal access token for gitea_ssh_setup.sh — registers this server's SSH public key
|
||||
# with Gitea so git operations use key auth instead of passwords.
|
||||
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user
|
||||
HOST1_GITEA_API_TOKEN=""
|
||||
|
||||
# ━━━ Notifications ━━━
|
||||
# Discord webhook — leave blank to disable.
|
||||
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
|
||||
HOST1_DISCORD_WEBHOOK=""
|
||||
|
||||
# ━━━ Partnership ━━━
|
||||
# HOST1 is always the owner (source of truth) unless --transfer has been run.
|
||||
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
|
||||
|
||||
# Auth containers reconfigured on onboard/offboard.
|
||||
# Format: "ContainerName|WebUIPort"
|
||||
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
|
||||
# On offboard → WebUI pointed back at localhost
|
||||
HOST1_PARTNERSHIP_AUTH_WEBUIS=(
|
||||
"NginxProxyManager|81"
|
||||
"Lldap-Gmer4Lfe|17170"
|
||||
"Authelia|9091"
|
||||
"Authelia-Secondary|9092"
|
||||
)
|
||||
|
||||
# XML templates (from this server's templates-user/) pushed to mirror during onboard.
|
||||
# These become the mirror's active auth stack, backed by the rsync-synced appdata.
|
||||
# Update filename if Lldap is renamed to drop the host suffix.
|
||||
HOST1_PARTNERSHIP_AUTH_STACK=(
|
||||
# Dependencies first — Mariadb/Redis must be healthy before Authelia starts
|
||||
"my-Mariadb-Authelia.xml"
|
||||
"my-Mariadb-Authelia-Secondary.xml"
|
||||
"my-Redis-Authelia.xml"
|
||||
"my-Redis-Authelia-Secondary.xml"
|
||||
# Auth apps — deployed after their deps are confirmed healthy
|
||||
"my-Authelia.xml"
|
||||
"my-Authelia-Secondary.xml"
|
||||
"my-NginxProxyManager.xml"
|
||||
"my-Lldap-Gmer4Lfe.xml"
|
||||
# Source of truth — must be available on HOST2 independently of the auth stack
|
||||
"my-Gitea.xml"
|
||||
)
|
||||
|
||||
# XML templates pushed to mirror for the arr stack during onboard.
|
||||
# Deps (e.g. databases) first if any — same ordering rule as auth stack.
|
||||
HOST1_PARTNERSHIP_ARR_STACK=(
|
||||
# "my-Sonarr.xml"
|
||||
# "my-Radarr.xml"
|
||||
# "my-Lidarr.xml"
|
||||
# "my-Prowlarr.xml"
|
||||
# "my-Bazarr.xml"
|
||||
)
|
||||
|
||||
# Paths HOST2 should collect during the grace window after offboard.
|
||||
# Notified on offboard — no auto-deletion, HOST2 must collect manually within PARTNERSHIP_GRACE_HOURS.
|
||||
HOST1_PARTNERSHIP_MIRROR_BACKUPS=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
|
||||
)
|
||||
|
||||
# Containers parked on this server when partnership is active.
|
||||
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
|
||||
HOST1_PARTNERSHIP_OWN_CONTAINERS=(
|
||||
# "Emby"
|
||||
# "NginxProxyManager"
|
||||
)
|
||||
|
||||
# Emby admin provisioning — toggle is owner-only, credentials are per-host.
|
||||
# Owner enables/disables the feature. Each host sets the account they want on the shared Emby.
|
||||
# On onboard: owner reads mirror's HOST*_PARTNERSHIP_EMBY_ADMIN_* and creates that account.
|
||||
# On offboard: account is deleted. Username collision → onboard exits with error.
|
||||
HOST1_PARTNERSHIP_PROVISION_EMBY_ADMIN=false # owner controls whether Emby is shared
|
||||
HOST1_PARTNERSHIP_EMBY_PORT=8096
|
||||
HOST1_PARTNERSHIP_EMBY_ADMIN_USER="" # this server's desired Emby username
|
||||
HOST1_PARTNERSHIP_EMBY_ADMIN_PASS="" # this server's desired Emby password
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Daily Sync Shares ━━━
|
||||
# Shares HOST1 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
|
||||
# Mesh model: every node pushes every media share — no ownership, no mirrors.
|
||||
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
|
||||
# arr_cleanup removes true orphans based on local arr state.
|
||||
# Any node can download content to any share — it propagates to all nodes on the next cycle.
|
||||
# Nextcloud is intentionally one-directional (HOST1→HOST2 offsite backup — not arr-managed).
|
||||
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
|
||||
# For shares needing container stops or custom options — add a profile in master.conf.
|
||||
HOST1_DAILY_SYNC_SHARES=(
|
||||
/mnt/user/Books
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/Nextcloud
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Sports
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
# Personal encrypted shares — synced for offsite backup, independent of media shares.
|
||||
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
|
||||
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
|
||||
HOST1_PERSONAL_SHARES=(
|
||||
# /mnt/user/HOST1-Personal # uncomment after creating encrypted dataset
|
||||
)
|
||||
|
||||
# ━━━ Weekly Sync Shares ━━━
|
||||
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
|
||||
# Containers stopped both sides before sync — full clean state guaranteed.
|
||||
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
|
||||
# Order matters — Emby first (larger transfer), then Critical-Data (auth stack).
|
||||
HOST1_WEEKLY_SYNC_SHARES=(
|
||||
"/mnt/user/Media_Server/Emby" # emby profile — full clean mirror
|
||||
"/mnt/user/appdata-Fallback/Critical-Data" # critical-data profile — auth stack
|
||||
)
|
||||
|
||||
# ━━━ Intermediate Sync Shares ━━━
|
||||
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
|
||||
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
|
||||
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
|
||||
HOST1_INTERMEDIATE_SYNC_SHARES=(
|
||||
# Add shares here to enable mid-day rsync
|
||||
# Example: "/mnt/user/Emby_Metadata"
|
||||
)
|
||||
|
||||
# ━━━ Critical Sync Shares ━━━
|
||||
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
|
||||
# Format: "/path/to/share" or "/path/to/share|profile-name"
|
||||
# Order matters — Critical-Data first (auth stack), then Emby dirty sync.
|
||||
HOST1_CRITICAL_SYNC_SHARES=(
|
||||
"/mnt/user/appdata-Fallback/Critical-Data|critical-fallback" # auth dirty sync — stays running
|
||||
"/mnt/user/Media_Server/Emby|emby-fallback" # Emby dirty sync — stays running
|
||||
)
|
||||
|
||||
# ━━━ Backup Verify ━━━
|
||||
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
|
||||
# Leave empty to use HOST1_DAILY_SYNC_SHARES automatically.
|
||||
# Sample size and minimum file size defined in master.conf.
|
||||
HOST1_BACKUP_VERIFY_SHARES=(
|
||||
# leave empty to use HOST1_DAILY_SYNC_SHARES automatically
|
||||
)
|
||||
|
||||
# ━━━ HOST1 Rsync Profile — host1-appdata ━━━
|
||||
# HOST1-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
|
||||
# Use for appdata unique to HOST1 (Organizrv2, VaultWarden, UptimeKuma etc.)
|
||||
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
|
||||
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST1-Appdata --profile=host1-appdata
|
||||
PROFILE_RSYNC_OPTS[host1-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host1-appdata]:-8000}"
|
||||
PROFILE_BW_LIMIT[host1-appdata]=8000
|
||||
PROFILE_RETRY_COUNT[host1-appdata]=3
|
||||
PROFILE_SLEEP[host1-appdata]=300
|
||||
PROFILE_CRITICAL_CONTAINER_NAMES[host1-appdata]="Organizrv2-Gmer4Lfe UptimeKuma-Gmer4Lfe VaultWarden-Gmer4Lfe"
|
||||
PROFILE_DELAYED_CONTAINERS[host1-appdata]=""
|
||||
PROFILE_CONTAINER_DELAY[host1-appdata]=5
|
||||
PROFILE_EXCLUDE_DIRS[host1-appdata]="logs *.tmp"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Docker Daily Restart ━━━
|
||||
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
|
||||
# Dispatcharr degrades over time without restart — daily is intentional, not just housekeeping.
|
||||
# Order matters — auth stack first, then media services.
|
||||
HOST1_DAILY_RESTART_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Lldap-Gmer4Lfe"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
"Dispatcharr-Iptv-Users"
|
||||
"Dispatcharr" # Live TV scheduler — degrades without daily restart
|
||||
"Dispatcharr-Basic"
|
||||
"ErsatzTV-Emby"
|
||||
"Slskd" # Soulseek connection drops after extended uptime; restart refreshes share index
|
||||
)
|
||||
|
||||
# ━━━ Docker Weekly Restart ━━━
|
||||
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
|
||||
# Containers already stopped for weekly sync — restart adds zero extra downtime.
|
||||
HOST1_WEEKLY_RESTART_CONTAINERS=(
|
||||
"NextCloud"
|
||||
"Organizrv2-Gmer4Lfe"
|
||||
"AdGuard-Home"
|
||||
"Immich-Gmer4Lfe"
|
||||
)
|
||||
|
||||
# ━━━ Docker Watchdog ━━━
|
||||
# Per-HOST1 container configuration for docker_watchdog.sh.
|
||||
# Shared thresholds and toggles live in master.conf.
|
||||
|
||||
# Memory hard limits in MB — immediate restart if exceeded.
|
||||
# Set at "container is clearly broken" not "container is busy".
|
||||
# 20GB=20480 18GB=18432 16GB=16384 12GB=12288 8GB=8192 4GB=4096 2GB=2048 1GB=1024
|
||||
declare -A HOST1_WATCHDOG_CONTAINERS=(
|
||||
["Emby"]=20480 # 20GB — large library + active transcodes
|
||||
["LidaTube"]=6144 # 6GB — memory leak over time
|
||||
["Tdarr"]=6144 # 6GB — encoding is memory intensive
|
||||
["Code-Server"]=1024 # 1GB — should never need more
|
||||
)
|
||||
|
||||
# HTTP health check URLs — checked every cycle, strike system before restart.
|
||||
# Only add containers with a meaningful web interface to check.
|
||||
declare -A HOST1_WATCHDOG_CONTAINER_URLS=(
|
||||
["Emby"]="http://localhost:8096"
|
||||
)
|
||||
|
||||
# Required containers — must always be running on HOST1.
|
||||
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
|
||||
# Listed in dependency order — dependencies before dependents.
|
||||
HOST1_WATCHDOG_REQUIRED_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Lldap-Gmer4Lfe"
|
||||
"Mariadb-Authelia"
|
||||
"Mariadb-Authelia-Secondary"
|
||||
"Redis-Authelia"
|
||||
"Redis-Authelia-Secondary"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
)
|
||||
|
||||
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
|
||||
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
|
||||
HOST1_WATCHDOG_SCAN_IGNORE=(
|
||||
"DashGate"
|
||||
"PIA-WG-Config-Generator"
|
||||
"Aperture"
|
||||
"Aperture-Kids"
|
||||
"pgvector-18-Apeture-Kids"
|
||||
"Pgvector18-Aperture"
|
||||
"emby-test" # broken test container (exit 127 — bad image)
|
||||
)
|
||||
|
||||
# Dependency ordering — skip restarting a container if its dependency is also down.
|
||||
# Prevents watchdog from restarting Authelia before Mariadb is back up.
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime.
|
||||
declare -A HOST1_WATCHDOG_DEPENDENCIES=(
|
||||
["Authelia"]="Mariadb-Authelia Redis-Authelia"
|
||||
["Authelia-Secondary"]="Mariadb-Authelia Redis-Authelia-Secondary"
|
||||
["NextCloud"]="Postgres-NextCloud"
|
||||
)
|
||||
|
||||
# Per-container appdata growth suppress ceilings in MB.
|
||||
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
|
||||
# Use this when a container legitimately has large stable data and you want to guarantee
|
||||
# it never triggers a false-positive growth alert. Growth warnings are suppressed while the
|
||||
# container's dir stays below this ceiling; above it, warnings resume as normal.
|
||||
# 50GB=51200 25GB=25600 20GB=20480 15GB=15360 10GB=10240 5GB=5120
|
||||
declare -A HOST1_WATCHDOG_APPDATA_SIZES=(
|
||||
["Tdarr"]="25600" # 25GB — transcode cache grows legitimately during active jobs
|
||||
["7dtd"]="20480" # 20GB — game server world data, expected to be large
|
||||
)
|
||||
|
||||
# ━━━ Network Watchdog ━━━
|
||||
# Host-specific connectivity config for Watchdogs/System/network_watchdog.sh.
|
||||
HOST1_NETWORK_WATCHDOG_DDNS_DOMAIN="gmer4lfe.com"
|
||||
HOST1_NETWORK_WATCHDOG_DDNS_CONTAINER="Gmer4Lfe.com"
|
||||
HOST1_NETWORK_WATCHDOG_NPM_URL="https://gmer4lfe.com"
|
||||
|
||||
# ━━━ Docker Network Connect ━━━
|
||||
# Containers connected to custom networks at array start by docker_network_connect.sh.
|
||||
# Networks created if they don't exist — idempotent, safe to re-run.
|
||||
HOST1_NETWORK_CONNECT_CONTAINERS=(
|
||||
"memcached"
|
||||
"Npm-CrowdSec"
|
||||
)
|
||||
|
||||
HOST1_NETWORK_CONNECT_NETWORKS=(
|
||||
"high-availability"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ DDNS ━━━
|
||||
# DDNS containers HOST1 manages — started/stopped by fallback.sh per DDNS absolute rules:
|
||||
# Internet loss → stop immediately
|
||||
# Failover → HOST2 starts HOST1's DDNS as Tier 1 (before any other containers)
|
||||
# Handback → stop HOST1's DDNS on HOST2 → rsync → start containers → start local DDNS last
|
||||
HOST1_DDNS_CONTAINERS=(
|
||||
"Gmer4Lfe.com"
|
||||
)
|
||||
|
||||
# ━━━ Internet Loss ━━━
|
||||
# Containers stopped immediately on HOST1 when internet connection is lost.
|
||||
# Prevents external-facing services from operating without connectivity.
|
||||
FALLBACK_HOST1_STOP_ON_NO_NET=(
|
||||
"Gmer4Lfe.com"
|
||||
)
|
||||
|
||||
# ━━━ Fallback Tiers — HOST1 Runs for HOST2 ━━━
|
||||
# Containers HOST1 starts when HOST2 goes down.
|
||||
# Tier 1 is always immediate — vital services cannot wait.
|
||||
# Higher tiers activate after HOST2_TIER*_DELAY minutes (set in host2.conf).
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER1=(
|
||||
"Gmer4Lfe.us"
|
||||
"VaultWarden-Jayred365"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER2=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER3=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_COVERS_HOST2_TIER4=(
|
||||
# "container-placeholder"
|
||||
)
|
||||
|
||||
# ━━━ Tier Delays — HOST1's Containers on HOST2 ━━━
|
||||
# How long HOST1 must be down before each tier activates on HOST2 — in minutes.
|
||||
# Tier 1 is always immediate — no delay var needed.
|
||||
HOST1_TIER2_DELAY=240 # 4 hours — NextCloud, Immich
|
||||
HOST1_TIER3_DELAY=720 # 12 hours — secondary services
|
||||
HOST1_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
|
||||
|
||||
# ━━━ Rsync Writeback — HOST1 Appdata Back on Handback ━━━
|
||||
# Syncs HOST1 appdata BACK to HOST1 when it comes back online after a failover.
|
||||
# Containers stopped before writeback — clean source, no competing writes.
|
||||
#
|
||||
# HOST1_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
|
||||
# is more reliable than dirty sync data for brief outages.
|
||||
HOST1_TIER1_WRITEBACK_DELAY=60 # skip Emby writeback if outage under 1hr
|
||||
|
||||
# Tier 4 automatically syncs HOST1_DAILY_SYNC_SHARES — only list paths NOT in that array.
|
||||
FALLBACK_HOST1_WRITEBACK_TIER1=(
|
||||
"/mnt/user/Media_Server/Emby" # watch states built up during outage
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER2=(
|
||||
"/mnt/user/appdata-Fallback/Important-Data" # NextCloud + Postgres — files added during outage
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER3=(
|
||||
# "location-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST1_WRITEBACK_TIER4=(
|
||||
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Media Permissions ━━━
|
||||
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
|
||||
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
|
||||
HOST1_MEDIA_PERMISSION_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/appcache
|
||||
/mnt/user/Books
|
||||
/mnt/user/Downloads
|
||||
/mnt/user/Games
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movie_Recordings
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/Photo
|
||||
/mnt/user/Sports
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Temp_Storage
|
||||
/mnt/user/Tv_Recordings
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/YouTube
|
||||
)
|
||||
|
||||
# ━━━ Media Cleaner ━━━
|
||||
# Folder lists for media_cleaner.sh — two profiles: anime and media.
|
||||
# File patterns shared across all servers — defined in master.conf.
|
||||
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
|
||||
HOST1_ANIME_CLEAN_FOLDERS=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Movies-Old
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
)
|
||||
|
||||
HOST1_MEDIA_CLEAN_FOLDERS=(
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Sports
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Tv_Shows
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Certificate Monitor ━━━
|
||||
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
|
||||
# Checks the actual certificate served, not what NPM thinks it has.
|
||||
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
|
||||
HOST1_CERT_MONITOR_DOMAINS=(
|
||||
"Gmer4Lfe.com"
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ SMART Health ━━━
|
||||
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
|
||||
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
|
||||
HOST1_SMART_IGNORE_DRIVES=(
|
||||
"sda" # boot USB — SMART not meaningful on flash drives
|
||||
)
|
||||
|
||||
# ━━━ ZFS Report ━━━
|
||||
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
|
||||
# These are individual array disks formatted as ZFS — converting to XFS over time via unBalance.
|
||||
# Pool health thresholds defined in master.conf.
|
||||
HOST1_ZFS_REPORT_IGNORE_POOLS=(
|
||||
"disk5"
|
||||
"disk6"
|
||||
"disk8"
|
||||
"disk9"
|
||||
"disk10"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
|
||||
# Real-world: 9 streams peaked at ~5.5GB — 10G gives generous headroom on 128GB RAM.
|
||||
HOST1_RAMDISK_SIZE="10G"
|
||||
|
||||
# Usage thresholds — coupled to HOST1_RAMDISK_SIZE, adjust all three together if size changes.
|
||||
# Hysteresis gap (8.5 - 7 = 1.5GB) prevents flip-flop between ramdisk and SSD.
|
||||
HOST1_RAMDISK_WARN_GB=8.5 # flip to SSD when ramdisk usage reaches this
|
||||
HOST1_RAMDISK_LOW_GB=7 # flip back to ramdisk when usage drops to this
|
||||
|
||||
# SSD fallback path — where transcodes land when ramdisk exceeds HOST1_RAMDISK_WARN_GB.
|
||||
# Must be on cache pool — array disks too slow for active transcode writes.
|
||||
HOST1_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
|
||||
|
||||
# Media servers sharing the ramdisk transcode space on HOST1.
|
||||
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
|
||||
# Entries with placeholder API keys are skipped automatically.
|
||||
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
|
||||
HOST1_TRANSCODE_SERVERS=(
|
||||
"${HOST1_EMBY_CONTAINER}|${HOST1_EMBY_URL}|${HOST1_EMBY_API_KEY}|emby"
|
||||
"${HOST1_JELLYFIN_CONTAINER}|${HOST1_JELLYFIN_URL}|${HOST1_JELLYFIN_API_KEY}|jellyfin"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
|
||||
# detect_hosts() selects HOST1 vars when running on HOST1.
|
||||
#
|
||||
# PATH MAPS — container path → host path translation.
|
||||
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
|
||||
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
|
||||
|
||||
# ━━━ Downloaders ━━━
|
||||
# Used by downloaders_reset.sh — runs every 30min via CRITICAL_MAINTENANCE_SCRIPTS.
|
||||
# Clears stuck states, purges old history, prepares each client for a clean cycle.
|
||||
|
||||
# slskd — clears stuck searches, dead transfers, purges expired failed imports.
|
||||
# SLSKD_FAILED_IMPORTS_DIR: where Soularr moves albums Lidarr rejected.
|
||||
HOST1_SLSKD_URL="http://localhost:8980"
|
||||
HOST1_SLSKD_API_KEY="4bF9kL2mNpQrT7vWxYz1A3dEgHjKoRsU"
|
||||
HOST1_SLSKD_FAILED_IMPORTS_DIR="/mnt/user/Temp_Storage/Slskd/completed/failed_imports"
|
||||
|
||||
# SABnzbd
|
||||
HOST1_SABNZBD_URL="http://localhost:8180"
|
||||
HOST1_SABNZBD_API_KEY="8bfefe41d83b4d50883e32859b55ca9a"
|
||||
|
||||
# qBittorrent — deleteFiles=false removes torrent from qBit but leaves files on disk.
|
||||
# Radarr/Sonarr manage actual files independently.
|
||||
HOST1_QBIT_URL="http://localhost:8080"
|
||||
HOST1_QBIT_USERNAME="root"
|
||||
HOST1_QBIT_PASSWORD="Stay0utD!ck"
|
||||
|
||||
# ━━━ Lidarr — HOST1 only ━━━
|
||||
# HOST2 does not run Lidarr — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
|
||||
HOST1_LIDARR_URL="http://localhost:8686"
|
||||
HOST1_LIDARR_API_KEY="b2977e71ef074bc0a0529d9fcce3b2dc"
|
||||
HOST1_LIDARR_MUSIC_ROOT="/mnt/user/Music-New"
|
||||
HOST1_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
|
||||
HOST1_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
|
||||
|
||||
declare -A HOST1_LIDARR_PATH_MAP=(
|
||||
["/ext-music"]="/mnt/user/Music-New"
|
||||
)
|
||||
|
||||
# ━━━ Sonarr ━━━
|
||||
HOST1_SONARR_URL="http://localhost:8989"
|
||||
HOST1_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
|
||||
HOST1_SONARR_TV_ROOT="/mnt/user/Tv_Shows"
|
||||
|
||||
# Note: stand-up_comedy in both Sonarr + Radarr — TV specials and movie specials, one folder
|
||||
declare -A HOST1_SONARR_PATH_MAP=(
|
||||
["/tv"]="/mnt/user/Tv_Shows"
|
||||
["/ext-standup-comedy"]="/mnt/user/stand-up_comedy"
|
||||
["/kids tv"]="/mnt/user/Kids_Tv_Shows"
|
||||
["/ext-anime-shows"]="/mnt/user/Anime_Shows-Old"
|
||||
)
|
||||
|
||||
# ━━━ Radarr ━━━
|
||||
HOST1_RADARR_URL="http://localhost:7878"
|
||||
HOST1_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
|
||||
HOST1_TMDB_API_KEY="3dac5e2e49b5540472d2eafec4f01260"
|
||||
HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies"
|
||||
|
||||
# Note: stand-up_comedy in both Radarr + Sonarr — movie specials and TV specials, one folder
|
||||
declare -A HOST1_RADARR_PATH_MAP=(
|
||||
["/movies"]="/mnt/user/Movies"
|
||||
["/kids movies"]="/mnt/user/Kids_Movies"
|
||||
["/ext-stand-up-comedy"]="/mnt/user/stand-up_comedy"
|
||||
["/anime-movies"]="/mnt/user/Anime_Movies-Old"
|
||||
)
|
||||
|
||||
# ━━━ Arr Recovery Toggles ━━━
|
||||
# false = skip that arr on this host — exits cleanly without error
|
||||
HOST1_SONARR_RECOVERY=true
|
||||
HOST1_RADARR_RECOVERY=true
|
||||
HOST1_LIDARR_RECOVERY=true # HOST1 only — exits cleanly on HOST2
|
||||
|
||||
# ==============================================================================================
|
||||
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Per-host check toggles and NIC config for system_watchdog.sh.
|
||||
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
|
||||
# HOST1: TR1950X 128GB — full media server, active transcoding, ZFS cache pools.
|
||||
#
|
||||
# Three-tier response — all critical checks enabled by default on HOST1:
|
||||
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
|
||||
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
|
||||
# Tier 3 (standard strike system): everything else
|
||||
#
|
||||
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
|
||||
|
||||
# ━━━ Primary NIC ━━━
|
||||
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
|
||||
# Common values: eth0, bond0, br0, eno1
|
||||
HOST1_SYS_WATCHDOG_NIC="eth0"
|
||||
|
||||
# ━━━ Tier 1 — Critical Checks ━━━
|
||||
# These bypass the strike system — a single hit triggers immediate reboot.
|
||||
# Disabling any of these is not recommended — they protect against acute system failure.
|
||||
|
||||
# Docker daemon unresponsive → try restart, reboot if restart fails.
|
||||
# Without a working daemon docker_watchdog.sh is blind and containers cannot be managed.
|
||||
HOST1_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
|
||||
|
||||
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
|
||||
# At 99% rootfs writes fail silently — logs stop, Docker errors out, SSH may stop working.
|
||||
# Standard 95% threshold still uses strike system — only 99%+ is critical tier.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ROOTFS=true
|
||||
|
||||
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
|
||||
# A kernel oops means the kernel ran with a corrupted state — stability is not guaranteed.
|
||||
HOST1_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
|
||||
|
||||
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
|
||||
# At 95% FD: new connections fail, Docker can't spawn processes, SSH drops.
|
||||
HOST1_SYS_WATCHDOG_CHECK_FD=true
|
||||
|
||||
# /boot read-only detected → reboot immediately.
|
||||
# Unexpected read-only /boot means state files and config writes are silently failing.
|
||||
# Fallback state, watchdog reboot log, and lock files all go stale silently.
|
||||
HOST1_SYS_WATCHDOG_CHECK_BOOT=true
|
||||
|
||||
# ━━━ Tier 2 — Urgent OOM Check ━━━
|
||||
# Bypass strikes when RAM is critically low AND OOM kill rate confirms active crisis.
|
||||
# Both must be enabled for Tier 2 bypass to function — disable either to always use strikes.
|
||||
|
||||
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
|
||||
# Also provides diagnostic context in reboot messages (which processes were killed).
|
||||
HOST1_SYS_WATCHDOG_CHECK_OOM=true
|
||||
|
||||
# Free RAM check — required for both Tier 2 bypass and RAM tier logic.
|
||||
# Tiers: MEM_WARN_GB(10) → notify | MEM_SHUTDOWN_GB(6) → stop containers | MEM_GB(4) → strikes
|
||||
HOST1_SYS_WATCHDOG_CHECK_RAM=true
|
||||
|
||||
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
|
||||
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action is taken.
|
||||
# Single spikes are ignored — sustained problems trigger reboot.
|
||||
|
||||
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
|
||||
# Log spam (Docker log storms, syslog loops) fills rootfs — indicates something broken.
|
||||
HOST1_SYS_WATCHDOG_CHECK_LOG=true
|
||||
|
||||
# ZFS ARC memory pinned above SYS_WATCHDOG_ARC_PINNED_PCT after cache drop.
|
||||
# Enabled on HOST1 — ZFS cache pools actively used. Disable on hosts without ZFS.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ARC=true
|
||||
|
||||
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
|
||||
# Sustained high temp causes kernel throttling or panic. Requires lm-sensors.
|
||||
HOST1_SYS_WATCHDOG_CHECK_CPU_TEMP=true
|
||||
|
||||
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
|
||||
# DISABLED on HOST1 — Tdarr and Emby cause legitimate sustained load spikes during encoding.
|
||||
# Enable on idle servers or adjust SYS_WATCHDOG_LOAD_MULTIPLIER if load is always high.
|
||||
HOST1_SYS_WATCHDOG_CHECK_LOAD=false
|
||||
|
||||
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
|
||||
# Large zombie counts indicate serious process management failure — something is stuck.
|
||||
HOST1_SYS_WATCHDOG_CHECK_ZOMBIES=true
|
||||
|
||||
# Check docker_watchdog.sh persistent skip list — required containers on skip list.
|
||||
# Cross-watchdog coordination: if docker_watchdog gave up, system_watchdog escalates.
|
||||
# ENABLED — HOST1 fully built and operational, skip list is meaningful.
|
||||
HOST1_SYS_WATCHDOG_CHECK_CONTAINERS=true
|
||||
|
||||
# /tmp filesystem usage above SYS_WATCHDOG_TMP_PCT with auto-clear attempt.
|
||||
# Script tries to clear aged /tmp files first — only strikes if clear fails.
|
||||
# Lock files, rsync temp files, and Docker ops use /tmp — 100% means lock failures.
|
||||
HOST1_SYS_WATCHDOG_CHECK_TMP=true
|
||||
|
||||
# Array disk error count delta in /proc/mdstat — accumulating errors = disk failing now.
|
||||
# Triggers on SYS_WATCHDOG_MDSTAT_ERROR_LIMIT new errors in one cycle.
|
||||
HOST1_SYS_WATCHDOG_CHECK_MDSTAT=true
|
||||
|
||||
# Primary NIC operstate — detects NIC going down (physical or driver failure).
|
||||
# Uses HOST1_SYS_WATCHDOG_NIC above. Strike system — brief flaps don't trigger reboot.
|
||||
HOST1_SYS_WATCHDOG_CHECK_NETWORK=true
|
||||
|
||||
# sshd running check — attempts restart before escalating.
|
||||
# sshd down = no remote access. Script tries rc.sshd start, notifies, strikes on failure.
|
||||
HOST1_SYS_WATCHDOG_CHECK_SSHD=true
|
||||
|
||||
# Runaway process detection — single process above SYS_WATCHDOG_RUNAWAY_CPU_PCT sustained.
|
||||
# DISABLED — Tdarr encoding and Emby transcoding legitimately peg CPU for extended periods.
|
||||
# Enable only if HOST1 has no CPU-intensive workloads.
|
||||
HOST1_SYS_WATCHDOG_CHECK_RUNAWAY=false
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
|
||||
|
||||
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
|
||||
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
|
||||
HOST1_RW_PAUSE_CONTAINERS=(
|
||||
"Huntarr" # arr search automation — safe to suspend
|
||||
"Cleanuparr" # download cleanup — safe to suspend
|
||||
"Healarr" # arr health checks — safe to suspend
|
||||
"Soularr" # Slskd automation — background only
|
||||
"ChannelTube" # YouTube archiver — background only
|
||||
"Pinchflat" # YouTube archiver — background only
|
||||
)
|
||||
|
||||
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
|
||||
# Full stop — these are optional/heavy services that free significant RAM when stopped.
|
||||
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
|
||||
HOST1_RW_STOP_CONTAINERS=(
|
||||
"LocalAI" # GPU/CPU heavy — largest RAM consumer when idle
|
||||
"7DaysToDie" # game server — optional
|
||||
"V-Rising" # game server — optional
|
||||
"Code-Server" # IDE — not needed during pressure events
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
|
||||
# NPM: Admin email + password from NginxProxyManager settings.
|
||||
# lldap: Admin username + password from lldap config.
|
||||
# Authelia: Path to configuration.yml — same path both hosts (synced via critical-data rsync).
|
||||
|
||||
# ━━━ NginxProxyManager ━━━
|
||||
HOST1_NPM_URL="http://localhost:81"
|
||||
HOST1_NPM_USER="failedproxy@gmail.com" # NPM admin email
|
||||
HOST1_NPM_PASS="183134\$eanHess" # NPM admin password
|
||||
|
||||
# ━━━ lldap ━━━
|
||||
HOST1_LLDAP_URL="http://localhost:17170"
|
||||
HOST1_LLDAP_USER="FailedProxy" # lldap admin username
|
||||
HOST1_LLDAP_PASS="183134\$eanHess" # lldap admin password
|
||||
|
||||
# ━━━ Authelia ━━━
|
||||
HOST1_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
|
||||
HOST1_AUTHELIA_CONTAINER="Authelia"
|
||||
|
||||
# ==============================================================================================
|
||||
# ──────────────────────── End Of HOST1 Variables ──────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => false, 'error' => 'POST only']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$action = $body['action'] ?? '';
|
||||
|
||||
$allowed = ['stop', 'shutdown', 'restart'];
|
||||
if (!in_array($action, $allowed, true)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'invalid action']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cmd = match($action) {
|
||||
'stop' => '/usr/local/sbin/mdcmd stop',
|
||||
'shutdown' => '/sbin/shutdown -h now',
|
||||
'restart' => '/sbin/shutdown -r now',
|
||||
};
|
||||
|
||||
$logLine = date('Y-m-d H:i:s') . " action={$action} ip=" . ($_SERVER['REMOTE_ADDR'] ?? 'unknown') . "\n";
|
||||
@file_put_contents('/boot/config/plugins/varaverk/actions.log', $logLine, FILE_APPEND | LOCK_EX);
|
||||
|
||||
exec($cmd . ' > /dev/null 2>&1 &');
|
||||
echo json_encode(['ok' => true]);
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => false, 'error' => 'POST only']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$action = $body['action'] ?? '';
|
||||
|
||||
$allowed = ['stop', 'shutdown', 'restart'];
|
||||
if (!in_array($action, $allowed, true)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'invalid action']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cmd = match($action) {
|
||||
'stop' => '/usr/local/sbin/mdcmd stop',
|
||||
'shutdown' => '/sbin/shutdown -h now',
|
||||
'restart' => '/sbin/shutdown -r now',
|
||||
};
|
||||
|
||||
$logLine = date('Y-m-d H:i:s') . " action={$action} ip=" . ($_SERVER['REMOTE_ADDR'] ?? 'unknown') . "\n";
|
||||
@file_put_contents(SCRIPTS_DIR . '/actions.log', $logLine, FILE_APPEND | LOCK_EX);
|
||||
|
||||
exec($cmd . ' > /dev/null 2>&1 &');
|
||||
echo json_encode(['ok' => true]);
|
||||
@@ -0,0 +1,62 @@
|
||||
Menu="Tasks:95"
|
||||
Title="Varaverk"
|
||||
Icon="varaverk.png"
|
||||
---
|
||||
<?php
|
||||
$plugin = 'varaverk';
|
||||
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
||||
$pluginDir = "$docroot/plugins/$plugin";
|
||||
|
||||
require_once "$pluginDir/include/config.php";
|
||||
|
||||
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$_host1_blank = empty(trim($_h1m[1] ?? ''));
|
||||
$_my_hostid = vv_detect_host();
|
||||
$_conf_missing = $_my_hostid !== 'unknown'
|
||||
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
|
||||
if ($_host1_blank || $_conf_missing) {
|
||||
include "$pluginDir/pages/setup.php";
|
||||
return;
|
||||
}
|
||||
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
|
||||
|
||||
// Determine active tab
|
||||
$tab = $_GET['tab'] ?? 'monitor';
|
||||
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'settings'];
|
||||
if (!in_array($tab, $validTabs)) $tab = 'monitor';
|
||||
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'settings' => 'Settings'];
|
||||
?>
|
||||
|
||||
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
|
||||
|
||||
<div id="varaverk-wrap">
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div id="vv-tabs">
|
||||
<?php foreach ($validTabs as $t): ?>
|
||||
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
|
||||
<?= $tabLabels[$t] ?? ucfirst($t) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
|
||||
style="margin-left:auto;padding:0 10px;font-size:10px;color:#333;text-decoration:none;
|
||||
display:flex;align-items:center;letter-spacing:.03em;"
|
||||
title="GitHub — source, issues, changelog">
|
||||
⎋ GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div id="vv-content">
|
||||
<?php
|
||||
$page = "$pluginDir/pages/$tab.php";
|
||||
if (file_exists($page)) include $page;
|
||||
else echo "<p>Page not found: $tab</p>";
|
||||
?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
|
||||
@@ -0,0 +1,62 @@
|
||||
Menu="Tasks:95"
|
||||
Title="Varaverk"
|
||||
Icon="varaverk.png"
|
||||
---
|
||||
<?php
|
||||
$plugin = 'varaverk';
|
||||
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
||||
$pluginDir = "$docroot/plugins/$plugin";
|
||||
|
||||
require_once "$pluginDir/include/config.php";
|
||||
|
||||
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$_host1_blank = empty(trim($_h1m[1] ?? ''));
|
||||
$_my_hostid = vv_detect_host();
|
||||
$_conf_missing = $_my_hostid !== 'unknown'
|
||||
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
|
||||
if ($_host1_blank || $_conf_missing) {
|
||||
include "$pluginDir/pages/setup.php";
|
||||
return;
|
||||
}
|
||||
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
|
||||
|
||||
// Determine active tab
|
||||
$tab = $_GET['tab'] ?? 'monitor';
|
||||
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
|
||||
if (!in_array($tab, $validTabs)) $tab = 'monitor';
|
||||
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings'];
|
||||
?>
|
||||
|
||||
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
|
||||
|
||||
<div id="varaverk-wrap">
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div id="vv-tabs">
|
||||
<?php foreach ($validTabs as $t): ?>
|
||||
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
|
||||
<?= $tabLabels[$t] ?? ucfirst($t) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
|
||||
style="margin-left:auto;padding:0 10px;font-size:10px;color:#333;text-decoration:none;
|
||||
display:flex;align-items:center;letter-spacing:.03em;"
|
||||
title="GitHub — source, issues, changelog">
|
||||
⎋ GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div id="vv-content">
|
||||
<?php
|
||||
$page = "$pluginDir/pages/$tab.php";
|
||||
if (file_exists($page)) include $page;
|
||||
else echo "<p>Page not found: $tab</p>";
|
||||
?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
|
||||
@@ -0,0 +1,65 @@
|
||||
Menu="Tasks:95"
|
||||
Title="Varaverk"
|
||||
Icon="varaverk.png"
|
||||
---
|
||||
<?php
|
||||
$plugin = 'varaverk';
|
||||
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
||||
$pluginDir = "$docroot/plugins/$plugin";
|
||||
|
||||
require_once "$pluginDir/include/config.php";
|
||||
|
||||
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$_host1_blank = empty(trim($_h1m[1] ?? ''));
|
||||
$_my_hostid = vv_detect_host();
|
||||
$_conf_missing = $_my_hostid !== 'unknown'
|
||||
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
|
||||
if ($_host1_blank || $_conf_missing) {
|
||||
include "$pluginDir/pages/setup.php";
|
||||
return;
|
||||
}
|
||||
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
|
||||
|
||||
// Determine active tab
|
||||
$tab = $_GET['tab'] ?? 'monitor';
|
||||
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
|
||||
if (!in_array($tab, $validTabs)) $tab = 'monitor';
|
||||
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings'];
|
||||
?>
|
||||
|
||||
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
|
||||
|
||||
<div id="varaverk-wrap">
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div id="vv-tabs">
|
||||
<?php foreach ($validTabs as $t): ?>
|
||||
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
|
||||
<?= $tabLabels[$t] ?? ucfirst($t) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<div style="margin-left:auto;display:flex;align-items:center;gap:2px;">
|
||||
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
|
||||
style="padding:0 10px;font-size:10px;color:#333;text-decoration:none;
|
||||
display:flex;align-items:center;letter-spacing:.03em;"
|
||||
title="GitHub — source, issues, changelog">
|
||||
⎋ GitHub
|
||||
</a>
|
||||
<button id="vv-expand-btn" onclick="vvToggleExpand()" title="Expand">⤢</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div id="vv-content">
|
||||
<?php
|
||||
$page = "$pluginDir/pages/$tab.php";
|
||||
if (file_exists($page)) include $page;
|
||||
else echo "<p>Page not found: $tab</p>";
|
||||
?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
|
||||
@@ -0,0 +1,65 @@
|
||||
Menu="Tasks:95"
|
||||
Title="Varaverk"
|
||||
Icon="varaverk.png"
|
||||
---
|
||||
<?php
|
||||
$plugin = 'varaverk';
|
||||
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
||||
$pluginDir = "$docroot/plugins/$plugin";
|
||||
|
||||
require_once "$pluginDir/include/config.php";
|
||||
|
||||
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$_host1_blank = empty(trim($_h1m[1] ?? ''));
|
||||
$_my_hostid = vv_detect_host();
|
||||
$_conf_missing = $_my_hostid !== 'unknown'
|
||||
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
|
||||
if ($_host1_blank || $_conf_missing) {
|
||||
include "$pluginDir/pages/setup.php";
|
||||
return;
|
||||
}
|
||||
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
|
||||
|
||||
// Determine active tab
|
||||
$tab = $_GET['tab'] ?? 'monitor';
|
||||
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'cert', 'rsync', 'auth', 'settings'];
|
||||
if (!in_array($tab, $validTabs)) $tab = 'monitor';
|
||||
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'cert' => 'Certs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings'];
|
||||
?>
|
||||
|
||||
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
|
||||
|
||||
<div id="varaverk-wrap">
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div id="vv-tabs">
|
||||
<?php foreach ($validTabs as $t): ?>
|
||||
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
|
||||
<?= $tabLabels[$t] ?? ucfirst($t) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<div style="margin-left:auto;display:flex;align-items:center;gap:2px;">
|
||||
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
|
||||
style="padding:0 10px;font-size:10px;color:#333;text-decoration:none;
|
||||
display:flex;align-items:center;letter-spacing:.03em;"
|
||||
title="GitHub — source, issues, changelog">
|
||||
⎋ GitHub
|
||||
</a>
|
||||
<button id="vv-expand-btn" onclick="vvToggleExpand()" title="Expand">⤢</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div id="vv-content">
|
||||
<?php
|
||||
$page = "$pluginDir/pages/$tab.php";
|
||||
if (file_exists($page)) include $page;
|
||||
else echo "<p>Page not found: $tab</p>";
|
||||
?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
|
||||
@@ -0,0 +1,65 @@
|
||||
Menu="Tasks:95"
|
||||
Title="Varaverk"
|
||||
Icon="varaverk.png"
|
||||
---
|
||||
<?php
|
||||
$plugin = 'varaverk';
|
||||
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
||||
$pluginDir = "$docroot/plugins/$plugin";
|
||||
|
||||
require_once "$pluginDir/include/config.php";
|
||||
|
||||
// First-run check — show setup wizard if HOST1 is blank OR local host.conf is missing
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$_host1_blank = empty(trim($_h1m[1] ?? ''));
|
||||
$_my_hostid = vv_detect_host();
|
||||
$_conf_missing = $_my_hostid !== 'unknown'
|
||||
&& !file_exists(CONF_DIR . '/' . $_my_hostid . '.conf');
|
||||
if ($_host1_blank || $_conf_missing) {
|
||||
include "$pluginDir/pages/setup.php";
|
||||
return;
|
||||
}
|
||||
unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing);
|
||||
|
||||
// Determine active tab
|
||||
$tab = $_GET['tab'] ?? 'monitor';
|
||||
$validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings'];
|
||||
if (!in_array($tab, $validTabs)) $tab = 'monitor';
|
||||
$tabLabels = ['monitor' => 'Monitor', 'scheduler' => 'Scheduler', 'docker' => 'Docker', 'watchdog' => 'Watchdog', 'partnership' => 'Partnership', 'fallback' => 'FallBack', 'arrs' => 'Arrs', 'rsync' => 'Rsync', 'auth' => 'Auth Stack', 'settings' => 'Settings'];
|
||||
?>
|
||||
|
||||
<link rel="stylesheet" href="/plugins/<?=$plugin?>/css/varaverk.css">
|
||||
|
||||
<div id="varaverk-wrap">
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div id="vv-tabs">
|
||||
<?php foreach ($validTabs as $t): ?>
|
||||
<a href="?tab=<?=$t?>" class="vv-tab<?= $t === $tab ? ' active' : '' ?>">
|
||||
<?= $tabLabels[$t] ?? ucfirst($t) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<div style="margin-left:auto;display:flex;align-items:center;gap:2px;">
|
||||
<a href="https://github.com/FailedProxy/Varaverk" target="_blank"
|
||||
style="padding:0 10px;font-size:10px;color:#333;text-decoration:none;
|
||||
display:flex;align-items:center;letter-spacing:.03em;"
|
||||
title="GitHub — source, issues, changelog">
|
||||
⎋ GitHub
|
||||
</a>
|
||||
<button id="vv-expand-btn" onclick="vvToggleExpand()" title="Expand">⤢</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div id="vv-content">
|
||||
<?php
|
||||
$page = "$pluginDir/pages/$tab.php";
|
||||
if (file_exists($page)) include $page;
|
||||
else echo "<p>Page not found: $tab</p>";
|
||||
?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/plugins/<?=$plugin?>/js/varaverk.js"></script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
// API cache writer — runs every minute via Varaverk scheduler.
|
||||
// Builds monitor + arrs payloads and writes them to /tmp/vv_cache/ so page
|
||||
// loads can serve instantly from the file instead of making live HTTP calls.
|
||||
//
|
||||
// Called by api_cache_writer.sh (bash wrapper required by the scheduler).
|
||||
|
||||
$_base = dirname(__DIR__) . '/Plugin/unraid';
|
||||
require_once $_base . '/include/monitor.php';
|
||||
require_once $_base . '/include/vms.php';
|
||||
require_once $_base . '/include/docker_folders.php';
|
||||
require_once $_base . '/include/arrs.php';
|
||||
|
||||
$t = microtime(true);
|
||||
|
||||
// ── Monitor payload ───────────────────────────────────────────────────────────
|
||||
// Call vv_api_data() once — result is static-cached for the rest of this process.
|
||||
vv_api_data();
|
||||
|
||||
$monitor = [
|
||||
'system' => vv_system_info(),
|
||||
'fallback' => vv_fallback_state(),
|
||||
'fallback_active' => vv_fallback_active(),
|
||||
'partner' => vv_partner_state(),
|
||||
'resources' => vv_system_resources(),
|
||||
'cpu' => vv_cpu_per_core(),
|
||||
'mem' => vv_memory_breakdown(),
|
||||
'net' => vv_network_stats(),
|
||||
'gpu' => vv_gpu_stats(),
|
||||
'gpu_procs' => vv_gpu_processes(),
|
||||
'containers' => vv_docker_containers(),
|
||||
'stopped' => vv_docker_stopped(),
|
||||
'transcode' => vv_transcode_sessions(),
|
||||
'ups' => vv_ups_stats(),
|
||||
'parity' => vv_parity_status(),
|
||||
'storage' => vv_storage_pools(),
|
||||
'array_disks' => vv_array_disks(),
|
||||
'disk_io' => vv_disk_io_rates(),
|
||||
'watchdog' => vv_watchdog_summary(),
|
||||
'scripts' => vv_scripts_status(),
|
||||
'rsync' => vv_rsync_status(),
|
||||
'thresholds' => vv_disk_thresholds(),
|
||||
'vms' => vv_get_vms(),
|
||||
'docker_folders' => vv_get_docker_folders(),
|
||||
'remote_hosts' => vv_remote_hosts_stats(),
|
||||
'_api_status' => vv_api_get_status(),
|
||||
'ts' => time(),
|
||||
];
|
||||
vv_cache_write('monitor', $monitor);
|
||||
|
||||
// ── Arrs payload ──────────────────────────────────────────────────────────────
|
||||
$arrs = vv_arrs_all();
|
||||
vv_cache_write('arrs', $arrs);
|
||||
|
||||
$elapsed = round((microtime(true) - $t) * 1000);
|
||||
echo "Cache written in {$elapsed}ms — monitor + arrs\n";
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
// API cache writer — runs every minute via Varaverk scheduler.
|
||||
// Builds monitor + arrs payloads and writes them to /tmp/vv_cache/ so page
|
||||
// loads can serve instantly from the file instead of making live HTTP calls.
|
||||
//
|
||||
// Called by api_cache_writer.sh (bash wrapper required by the scheduler).
|
||||
|
||||
$_base = dirname(__DIR__);
|
||||
require_once $_base . '/include/monitor.php';
|
||||
require_once $_base . '/include/vms.php';
|
||||
require_once $_base . '/include/docker_folders.php';
|
||||
require_once $_base . '/include/arrs.php';
|
||||
|
||||
$t = microtime(true);
|
||||
|
||||
// ── Monitor payload ───────────────────────────────────────────────────────────
|
||||
// Call vv_api_data() once — result is static-cached for the rest of this process.
|
||||
vv_api_data();
|
||||
|
||||
$monitor = [
|
||||
'system' => vv_system_info(),
|
||||
'fallback' => vv_fallback_state(),
|
||||
'fallback_active' => vv_fallback_active(),
|
||||
'partner' => vv_partner_state(),
|
||||
'resources' => vv_system_resources(),
|
||||
'cpu' => vv_cpu_per_core(),
|
||||
'mem' => vv_memory_breakdown(),
|
||||
'net' => vv_network_stats(),
|
||||
'gpu' => vv_gpu_stats(),
|
||||
'gpu_procs' => vv_gpu_processes(),
|
||||
'containers' => vv_docker_containers(),
|
||||
'stopped' => vv_docker_stopped(),
|
||||
'transcode' => vv_transcode_sessions(),
|
||||
'ups' => vv_ups_stats(),
|
||||
'parity' => vv_parity_status(),
|
||||
'storage' => vv_storage_pools(),
|
||||
'array_disks' => vv_array_disks(),
|
||||
'disk_io' => vv_disk_io_rates(),
|
||||
'watchdog' => vv_watchdog_summary(),
|
||||
'scripts' => vv_scripts_status(),
|
||||
'rsync' => vv_rsync_status(),
|
||||
'thresholds' => vv_disk_thresholds(),
|
||||
'vms' => vv_get_vms(),
|
||||
'docker_folders' => vv_get_docker_folders(),
|
||||
'remote_hosts' => vv_remote_hosts_stats(),
|
||||
'_api_status' => vv_api_get_status(),
|
||||
'ts' => time(),
|
||||
];
|
||||
vv_cache_write('monitor', $monitor);
|
||||
|
||||
// ── Arrs payload ──────────────────────────────────────────────────────────────
|
||||
$arrs = vv_arrs_all();
|
||||
vv_cache_write('arrs', $arrs);
|
||||
|
||||
$elapsed = round((microtime(true) - $t) * 1000);
|
||||
echo "Cache written in {$elapsed}ms — monitor + arrs\n";
|
||||
@@ -0,0 +1,931 @@
|
||||
/* Varaverk plugin styles — inherits unRAID theme, adds plugin-specific layout */
|
||||
|
||||
#varaverk-wrap { padding: 10px; font-family: inherit; }
|
||||
|
||||
/* Tab bar */
|
||||
#vv-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 2px solid #444; }
|
||||
.vv-tab { padding: 6px 16px; text-decoration: none; color: #aaa; border-radius: 4px 4px 0 0; }
|
||||
.vv-tab:hover { color: #fff; background: #333; }
|
||||
.vv-tab.active { color: #fff; background: #555; border-bottom: 2px solid #fff; }
|
||||
|
||||
/* Cards / layout */
|
||||
.vv-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.vv-card { flex: 1; min-width: 200px; background: #1e1e1e; border: 1px solid #444;
|
||||
border-radius: 6px; padding: 12px; }
|
||||
.vv-wide { flex: 100%; }
|
||||
.vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase;
|
||||
color: #888; letter-spacing: 0.05em; white-space: normal;
|
||||
overflow: hidden; min-width: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; }
|
||||
|
||||
/* Cog icon — links to related Unraid page. Hidden until card hovered. */
|
||||
.vv-card-cog { color: #2a2a2a; font-size: 13px; line-height: 1; text-decoration: none;
|
||||
padding: 1px 3px; border-radius: 3px; flex-shrink: 0;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
font-style: normal; font-weight: normal; text-transform: none;
|
||||
letter-spacing: 0; cursor: pointer; }
|
||||
.vv-card:hover .vv-card-cog { color: #4a4a4a; }
|
||||
.vv-card-cog:hover { color: #aaa !important; background: #333; }
|
||||
|
||||
/* Card header icon wrapper */
|
||||
.vv-ico { display:inline-flex; align-items:center; opacity:0.38; flex-shrink:0; }
|
||||
|
||||
/* Status accent — left border colour for state-driven cards */
|
||||
.vv-accent-ok { border-left-color: #2a5a2a !important; }
|
||||
.vv-accent-warn { border-left-color: #5a4000 !important; }
|
||||
.vv-accent-err { border-left-color: #5a1e1e !important; }
|
||||
#vv-monitor .vv-card { transition: border-left-color 0.5s; }
|
||||
|
||||
/* Status banner — coloured strip at top of card body */
|
||||
.vv-banner { border-radius: 4px; padding: 5px 10px; margin-bottom: 10px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 12px; font-weight: 600; }
|
||||
.vv-banner-ok { background: #061306; border: 1px solid #1a401a; color: #4caf50; }
|
||||
.vv-banner-warn { background: #130e00; border: 1px solid #3d2e00; color: #ff9800; }
|
||||
.vv-banner-err { background: #140404; border: 1px solid #3d1010; color: #f44336; }
|
||||
.vv-banner-off { background: #0d0d0d; border: 1px solid #252525; color: #555; }
|
||||
|
||||
/* System card — no h3, no top padding waste */
|
||||
#vv-system { padding-top: 14px; }
|
||||
|
||||
/* System action buttons */
|
||||
.vv-sys-btn { background: #2a2a2a; border: 1px solid #e65100; color: #ff9800; border-radius: 3px;
|
||||
padding: 3px 0; font-size: 6px; cursor: pointer; line-height: 1;
|
||||
width: 50px; min-width: 0; text-align: center; }
|
||||
.vv-sys-btn:hover { background: #3a2000; color: #ffb74d; border-color: #ff9800; }
|
||||
|
||||
/* Fallback state badge */
|
||||
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; margin-bottom: 2px; }
|
||||
.vv-state-normal { color: #4caf50; }
|
||||
.vv-state-failover { color: #f44336; }
|
||||
.vv-state-no_internet { color: #ff9800; }
|
||||
.vv-state-dark { color: #9e9e9e; }
|
||||
.vv-state-unknown { color: #666; }
|
||||
|
||||
/* Docker table */
|
||||
#vv-docker-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
#vv-docker-table th { text-align: left; padding: 4px 8px; color: #888;
|
||||
border-bottom: 1px solid #444; }
|
||||
#vv-docker-table td { padding: 4px 8px; border-bottom: 1px solid #2a2a2a; }
|
||||
.vv-status-up { color: #4caf50; }
|
||||
.vv-status-down { color: #f44336; }
|
||||
|
||||
/* Scheduler */
|
||||
.vv-hint { color: #888; font-size: 13px; margin-bottom: 16px; }
|
||||
.vv-sched-card { margin-bottom: 10px; }
|
||||
.vv-script { background: #161616; border: 1px solid #333; border-radius: 4px;
|
||||
padding: 6px 10px; margin: 4px 0; margin-left: 20px; }
|
||||
.vv-job-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
transition: background 0.1s, box-shadow 0.1s; }
|
||||
/* Orchestrator top row — clickable, show pointer + hover feedback */
|
||||
.vv-orch-row { cursor: pointer; border-radius: 4px; }
|
||||
.vv-orch-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.vv-job-label { flex: 1; font-size: 16px; font-weight: bold;
|
||||
color: #6fcf97; min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-job-actions { display: flex; align-items: center; gap: 8px; padding-left: 44px; margin-top: 6px; }
|
||||
.vv-job-desc { font-size: 14px; color: #777; margin: 3px 0 2px 0;
|
||||
padding-left: 44px; box-sizing: border-box; width: 100%;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
cursor: default; }
|
||||
.vv-cron { flex: 0 0 80px; width: 80px; background: #111; border: 1px solid #444; color: #ddd;
|
||||
padding: 4px 6px; border-radius: 4px; font-family: monospace; font-size: 11px; }
|
||||
.vv-event-badge { flex: 0 0 auto; padding: 3px 8px; border-radius: 4px; font-size: 12px;
|
||||
background: #1a3a1a; border: 1px solid #2e6b2e; color: #6fcf6f;
|
||||
white-space: nowrap; font-weight: 500; }
|
||||
.vv-flag-badge { flex: 0 0 auto; padding: 2px 6px; border-radius: 3px; font-size: 11px;
|
||||
background: #2e2200; border: 1px solid #6b4e00; color: #d4a017;
|
||||
white-space: nowrap; font-family: monospace; }
|
||||
.vv-log-label { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #888;
|
||||
cursor: pointer; white-space: nowrap; flex-shrink: 0; }
|
||||
.vv-log-label input { cursor: pointer; accent-color: #4caf50; }
|
||||
.vv-log-label:has(input:checked) { color: #4caf50; }
|
||||
.vv-children { padding-top: 8px; border-top: 1px solid #333; margin-top: 8px; }
|
||||
.vv-advanced-toggle { background: none; border: 1px solid #555; color: #aaa;
|
||||
padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
.vv-advanced-toggle:hover { border-color: #888; color: #fff; }
|
||||
.vv-btn-sm { padding: 3px 10px; background: #2a2a2a; border: 1px solid #555; color: #ccc;
|
||||
border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; }
|
||||
.vv-btn-sm:hover { border-color: #888; color: #fff; }
|
||||
.vv-btn-sm.active { border-color: #4caf50; color: #4caf50; }
|
||||
|
||||
/* Save checkmark */
|
||||
.vv-save-check { color: #4caf50; font-size: 13px; width: 14px; flex-shrink: 0;
|
||||
opacity: 0; text-align: center; }
|
||||
@keyframes vv-check-fade { 0%,60% { opacity: 1; } 100% { opacity: 0; } }
|
||||
.vv-save-check.vv-check-show { animation: vv-check-fade 2s forwards; }
|
||||
|
||||
/* Running dot */
|
||||
.vv-job-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.vv-dot-running { background: #4caf50; animation: vv-pulse-dot 1s ease-in-out infinite; }
|
||||
#vv-log-dot { animation: vv-pulse-dot 1s ease-in-out infinite; }
|
||||
@keyframes vv-pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
|
||||
|
||||
/* Selected job row — editor-style left accent + blue tint */
|
||||
.vv-row-selected { background: rgba(100,149,237,0.1); border-radius: 4px;
|
||||
outline: 1px solid rgba(100,149,237,0.35);
|
||||
box-shadow: inset 3px 0 0 #6495ed; }
|
||||
|
||||
/* Two-panel layout */
|
||||
#vv-sched-layout { display: flex; gap: 16px; align-items: stretch; }
|
||||
#vv-sched-left { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-self: flex-start; }
|
||||
#vv-sched-cards { flex: 1; }
|
||||
#vv-sched-right { display: none; flex: 1 1 0; min-width: 0; flex-direction: column; overflow: hidden; align-self: flex-start; }
|
||||
#vv-sched-right.vv-panel-visible { display: flex; }
|
||||
.vv-log-card { flex: 1; display: flex; flex-direction: column; padding-bottom: 0; }
|
||||
.vv-log-right-pre { max-height: none; overflow-y: auto; }
|
||||
|
||||
/* Scheduler stacked layout (narrow viewport) */
|
||||
@media (max-width: 900px) {
|
||||
#vv-sched-layout { flex-direction: column; align-items: stretch; }
|
||||
#vv-sched-left { flex: none; width: 100%; }
|
||||
#vv-sched-right { flex-direction: column; width: 100%; overflow: visible; }
|
||||
.vv-log-right-pre { min-height: 520px; max-height: 680px; }
|
||||
/* Toolbar: stack title row above buttons row, let buttons wrap */
|
||||
.vv-log-toolbar { flex-direction: column; align-items: flex-start; gap: 6px; }
|
||||
.vv-log-toolbar > div { flex-wrap: wrap; gap: 6px !important; }
|
||||
/* Array-event rows: badge already shows the event, cron input is redundant */
|
||||
.vv-job-row:has(.vv-event-badge) .vv-cron { display: none; }
|
||||
}
|
||||
|
||||
/* Plugin settings row (Advanced mode) */
|
||||
.vv-nb-settings { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 5px 10px; background: #101010; border-bottom: 1px solid #1a1a1a; }
|
||||
|
||||
/* ── Monitor: locked row height + scrollable cards ────────────────────────── */
|
||||
|
||||
/* Cards on the monitor grid are flex columns — h3 pins, body scrolls */
|
||||
#vv-monitor .vv-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
#vv-monitor .vv-card h3 { flex-shrink: 0; }
|
||||
#vv-monitor .vv-card > div {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
#vv-monitor .vv-card > div::-webkit-scrollbar { display: none; }
|
||||
/* Hide scrollbars on any nested scrollable div inside monitor cards */
|
||||
#vv-monitor .vv-card div::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Dynamic row heights capped per screen tier — rows size to content, never exceed the cap.
|
||||
minmax(0, Xpx): track is content-driven but capped; align-items:stretch makes all cards
|
||||
in a row fill the track, so short cards (Pools, Watchdog) match tall ones (Array).
|
||||
Breakpoints are viewport height (after browser chrome), not screen height. */
|
||||
|
||||
/* ~720p (viewport ≤ 700px) */
|
||||
@media (min-width: 481px) and (max-height: 700px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 160px) / 4)); }
|
||||
}
|
||||
/* ~1080p (viewport 701–1100px) */
|
||||
@media (min-width: 481px) and (min-height: 701px) and (max-height: 1100px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 240px) / 4)); }
|
||||
}
|
||||
/* ~1440p (viewport 1101–1450px) — calibrated on 15" 1440p display */
|
||||
@media (min-width: 481px) and (min-height: 1101px) and (max-height: 1450px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 335px) / 4)); }
|
||||
}
|
||||
/* ~4K (viewport > 1450px) */
|
||||
@media (min-width: 481px) and (min-height: 1451px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 500px) / 4)); }
|
||||
}
|
||||
|
||||
/* Mobile: natural heights, let page scroll */
|
||||
@media (max-width: 480px) {
|
||||
#vv-monitor { grid-auto-rows: auto !important; }
|
||||
#vv-monitor .vv-card { overflow: visible !important; }
|
||||
#vv-monitor .vv-card > div { overflow-y: visible; min-height: auto; }
|
||||
}
|
||||
|
||||
/* Monitor responsive — 4-column grid at medium width */
|
||||
@media (max-width: 1400px) {
|
||||
/* CPU core bars — reduce gap/min-width at intermediate widths before cores get clipped */
|
||||
.vv-cpu-cores { gap: 2px !important; }
|
||||
.vv-cpu-core { min-width: 7px !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
#vv-monitor { grid-template-columns: repeat(4, 1fr) !important; }
|
||||
#vv-docker { grid-column: span 4 !important; }
|
||||
/* Reset explicit placements so cards reflow in the 4-col grid */
|
||||
#vv-docker-folders { grid-column: span 4 !important; }
|
||||
#vv-parity-card { grid-column: auto !important; }
|
||||
#vv-storage-card { grid-column: auto !important; }
|
||||
#vv-array-card { grid-column: auto !important; }
|
||||
/* Streams header: hide right chip group entirely, keep server badges + media type */
|
||||
.vv-stream-right { display: none; }
|
||||
}
|
||||
|
||||
/* Containers+VMs: single column when viewport is narrow */
|
||||
@media (max-width: 900px) {
|
||||
.vv-df-cols { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
|
||||
/* Phone layout — scheduler row/actions fixes + monitor single-column */
|
||||
@media (max-width: 480px) {
|
||||
/* Phone portrait — hint gone, cron stays inline but compact */
|
||||
.vv-cron-hint { display: none !important; }
|
||||
.vv-job-row .vv-cron { flex: 0 0 68px; width: 68px; font-size: 10px; margin-left: 30px; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* Allow action buttons to wrap rather than overflow the card */
|
||||
.vv-job-actions { flex-wrap: wrap; padding-left: 0; }
|
||||
/* Kill the inline margin-left:auto that pushes Advanced off-screen */
|
||||
.vv-advanced-toggle { margin-left: 0 !important; width: auto !important; }
|
||||
/* Keep cron from growing — label owns remaining row space */
|
||||
.vv-cron { flex: 0 0 75px; width: 75px; }
|
||||
/* Slightly tighter label on narrow screens */
|
||||
.vv-job-label { font-size: 14px; }
|
||||
/* Footer buttons wrap instead of overflowing */
|
||||
.vv-sched-footer { flex-wrap: wrap; }
|
||||
/* Log toolbar search — narrow on small screens */
|
||||
#vv-log-search { width: 80px; }
|
||||
/* Snapshot footer — smaller on mobile */
|
||||
.vv-snap-footer { gap: 10px !important; }
|
||||
.vv-snap-item { gap: 4px; }
|
||||
.vv-snap-label { font-size: 10px; }
|
||||
.vv-snap-bar { width: 44px; height: 5px; }
|
||||
.vv-snap-val { font-size: 11px; min-width: 26px; }
|
||||
.vv-snap-div { font-size: 11px; }
|
||||
.vv-snap-state { font-size: 11px; }
|
||||
#vv-snap-partner { font-size: 11px; }
|
||||
.vv-snap-media { font-size: 11px; }
|
||||
|
||||
/* CPU core bars — shrink gap and min-width so many cores don't overflow */
|
||||
.vv-cpu-cores { gap: 2px !important; }
|
||||
.vv-cpu-core { min-width: 6px !important; }
|
||||
|
||||
/* Monitor single-column — explicit placement cards need override too */
|
||||
#vv-monitor { grid-template-columns: 1fr !important; }
|
||||
#vv-monitor > .vv-card { grid-column: 1 / -1 !important; }
|
||||
#vv-docker-folders { grid-column: 1 / -1 !important; }
|
||||
}
|
||||
|
||||
/* Shared footer (Save Schedule left, info right) — same min-height so log card ends level with script cards */
|
||||
.vv-sched-footer { display: flex; align-items: center; gap: 10px;
|
||||
margin-top: 12px; padding: 10px 0; border-top: 1px solid #333;
|
||||
flex-shrink: 0; min-height: 72px; box-sizing: border-box; }
|
||||
.vv-sched-info { color: #666; font-size: 14px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.vv-save-btn { padding: 5px 18px; background: #4caf50; border: none; color: #fff;
|
||||
border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
.vv-save-btn:hover { background: #388e3c; }
|
||||
.vv-save-status { font-size: 12px; color: #aaa; }
|
||||
.vv-run-btn { border-color: #2196f3 !important; color: #2196f3 !important; }
|
||||
.vv-run-btn:hover { background: #0d47a1 !important; color: #fff !important;
|
||||
border-color: #2196f3 !important; }
|
||||
.vv-dry-btn { border-color: #ff9800 !important; color: #ff9800 !important; }
|
||||
.vv-dry-btn:hover { background: #e65100 !important; color: #fff !important;
|
||||
border-color: #ff9800 !important; }
|
||||
.vv-log-btn { border-color: #555 !important; color: #666 !important; }
|
||||
.vv-log-btn:hover { background: #333 !important; color: #aaa !important; border-color: #777 !important; }
|
||||
.vv-log-btn.vv-has-log { border-color: #4caf50 !important; color: #4caf50 !important; }
|
||||
.vv-log-btn.vv-has-log:hover { background: #1b5e20 !important; color: #fff !important;
|
||||
border-color: #4caf50 !important; }
|
||||
.vv-edit-btn { border-color: #7b1fa2 !important; color: #ce93d8 !important; }
|
||||
.vv-edit-btn:hover { background: #4a148c !important; color: #fff !important; border-color: #7b1fa2 !important; }
|
||||
.vv-add-script-btn { background: #1565c0 !important; border: none !important; color: #fff !important; margin-left: 8px; }
|
||||
.vv-add-script-btn:hover { background: #0d47a1 !important; }
|
||||
.vv-save-script-btn-style { background: #1565c0 !important; border: none !important; color: #fff !important; }
|
||||
.vv-save-script-btn-style:hover { background: #0d47a1 !important; }
|
||||
.vv-delete-btn { background: #b71c1c !important; border: none !important; color: #fff !important; margin-left: 4px; }
|
||||
.vv-delete-btn:hover { background: #7f0000 !important; }
|
||||
.vv-conf-btn { border-color: #00838f !important; color: #4dd0e1 !important; }
|
||||
.vv-conf-btn:hover { background: #006064 !important; color: #fff !important; border-color: #00838f !important; }
|
||||
|
||||
/* Config form */
|
||||
#vv-confform { padding: 2px 4px; }
|
||||
.vv-cf-group { border-bottom: 1px solid #252525; padding-bottom: 14px; margin-bottom: 14px; }
|
||||
.vv-cf-group:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
.vv-cf-group-header { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: bold;
|
||||
color: #7a9eb5; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
|
||||
.vv-cf-file { font-size: 10px; color: #555; background: #1a1a1a; border: 1px solid #2e2e2e;
|
||||
padding: 1px 6px; border-radius: 3px; font-weight: normal; text-transform: none;
|
||||
letter-spacing: 0; }
|
||||
.vv-cf-field { margin-bottom: 10px; }
|
||||
.vv-cf-key { font-family: monospace; font-size: 12px; color: #ccc; margin-bottom: 3px; }
|
||||
.vv-cf-desc { font-size: 11px; color: #666; margin-bottom: 4px; font-style: italic; line-height: 1.4; }
|
||||
.vv-cf-scalar { display: block; width: 100%; box-sizing: border-box; background: #111; border: 1px solid #333;
|
||||
color: #ddd; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; }
|
||||
.vv-cf-scalar:focus { border-color: #555; outline: none; }
|
||||
.vv-cf-array { display: block; width: 100%; box-sizing: border-box; background: #0d0d0d; border: 1px solid #333;
|
||||
color: #ccc; padding: 8px; border-radius: 4px; font-family: monospace; font-size: 11px;
|
||||
line-height: 1.6; resize: vertical; min-height: 60px; }
|
||||
.vv-cf-array:focus { border-color: #555; outline: none; }
|
||||
.vv-cf-empty { color: #555; font-size: 13px; font-style: italic; padding: 20px 4px; text-align: center; margin: 0; }
|
||||
.vv-custom-empty { color: #666; font-size: 13px; padding: 8px 4px; margin: 0; font-style: italic; }
|
||||
.vv-custom-count { font-size: 12px; color: #666; margin-left: 8px; flex-shrink: 0; }
|
||||
.vv-section-sep { font-size: 11px; font-weight: bold; color: #666; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; padding: 10px 4px 4px; border-top: 1px solid #222; margin-top: 8px; }
|
||||
#vv-editor { flex-direction: column; gap: 0; }
|
||||
.vv-editor-body { font-family: monospace; font-size: 12px; background: #0d0d0d; color: #ccc;
|
||||
border: 1px solid #333; border-radius: 4px; padding: 10px 12px; resize: none;
|
||||
line-height: 1.5; scroll-behavior: auto; tab-size: 2; width: 100%; box-sizing: border-box;
|
||||
white-space: pre; overflow-x: auto; }
|
||||
|
||||
/* Editor layout: gutter + inner area — unified bordered block */
|
||||
#vv-editor-wrap { display: flex; border: 1px solid #2e2e2e; border-radius: 4px 4px 0 0;
|
||||
overflow: hidden; background: #0d0d0d; }
|
||||
#vv-ln-gutter { width: 46px; min-width: 46px; flex-shrink: 0;
|
||||
background: #0a0a0a; border-right: 1px solid #1c1c1c;
|
||||
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
|
||||
color: #3a3a3a; text-align: right; padding: 10px 8px 10px 0;
|
||||
overflow: hidden; user-select: none; white-space: pre; }
|
||||
.vv-gln-cur { color: #ccc; font-weight: bold; }
|
||||
#vv-editor-inner { position: relative; flex: 1; overflow: hidden; background: #0d0d0d; }
|
||||
|
||||
/* Current line highlight — sits behind overlay */
|
||||
#vv-cur-line { position: absolute; left: 0; right: 0; height: 18px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-top: 1px solid rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
pointer-events: none; display: none; }
|
||||
|
||||
/* Syntax-highlight overlay — transparent bg so cur-line shows through */
|
||||
#vv-hl-overlay { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
margin: 0; padding: 10px 12px; box-sizing: border-box;
|
||||
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
|
||||
white-space: pre; overflow: hidden;
|
||||
pointer-events: none; user-select: none;
|
||||
background: transparent; border: none; border-radius: 0; }
|
||||
.vv-editor-hl #vv-hl-overlay { display: block; }
|
||||
.vv-editor-hl #vv-editor-body { color: transparent; caret-color: #ddd; background: transparent;
|
||||
border: none !important; border-radius: 0 !important; }
|
||||
|
||||
/* Visible selection — works even when text is transparent */
|
||||
.vv-editor-hl #vv-editor-body::selection { background: rgba(100,149,237,0.35); color: transparent; }
|
||||
.vv-editor-hl #vv-editor-body::-moz-selection{ background: rgba(100,149,237,0.35); color: transparent; }
|
||||
|
||||
/* Word match and search marks in the overlay */
|
||||
.vv-word-mark { background: rgba(255,220,60,0.14); outline: 1px solid rgba(255,220,60,0.35); border-radius: 2px; }
|
||||
.vv-search-mark { background: rgba(255,140,20,0.22); outline: 1px solid rgba(255,140,20,0.45); border-radius: 2px; }
|
||||
.vv-search-mark-current{ background: rgba(255,90,0,0.40); outline: 2px solid rgba(255,100,0,0.7); border-radius: 2px; }
|
||||
|
||||
/* Slim scrollbar for editor */
|
||||
#vv-editor-body::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
#vv-editor-body::-webkit-scrollbar-track { background: #0a0a0a; }
|
||||
#vv-editor-body::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 4px; }
|
||||
#vv-editor-body::-webkit-scrollbar-thumb:hover { background: #444; }
|
||||
|
||||
/* Editor find bar — two-row (find + optional replace) */
|
||||
#vv-editor-find { display: none; flex-direction: column; gap: 0; padding: 5px 8px;
|
||||
margin-bottom: 5px; background: #161616; border: 1px solid #333;
|
||||
border-radius: 4px; font-size: 12px; }
|
||||
#vv-editor-find.vv-find-open { display: flex; }
|
||||
.vv-find-row { display: flex; align-items: center; gap: 6px; }
|
||||
.vv-replace-row { display: none; align-items: center; gap: 6px;
|
||||
margin-top: 4px; padding-top: 4px; border-top: 1px solid #222; }
|
||||
.vv-replace-row.vv-repl-open { display: flex; }
|
||||
.vv-find-expand { font-size: 10px; color: #555; cursor: pointer; flex-shrink: 0;
|
||||
width: 14px; text-align: center; user-select: none;
|
||||
transition: transform 0.15s; line-height: 1; }
|
||||
.vv-find-expand:hover { color: #aaa; }
|
||||
.vv-find-expand.open { transform: rotate(90deg); color: #888; }
|
||||
#vv-find-input, #vv-replace-input {
|
||||
flex: 1; min-width: 0; background: #0d0d0d; border: 1px solid #333;
|
||||
color: #ddd; padding: 4px 8px; border-radius: 3px;
|
||||
font-family: monospace; font-size: 12px; }
|
||||
#vv-find-input:focus, #vv-replace-input:focus {
|
||||
border-color: #6495ed; outline: none; box-shadow: 0 0 0 1px rgba(100,149,237,0.25); }
|
||||
#vv-find-input.vv-find-no-match { border-color: #c62828; box-shadow: 0 0 0 1px rgba(198,40,40,0.3); }
|
||||
.vv-find-count { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; min-width: 64px; text-align: right; }
|
||||
.vv-find-nav-btn{ padding: 2px 8px !important; }
|
||||
.vv-repl-btn { border-color: #444 !important; color: #777 !important; }
|
||||
.vv-repl-btn:hover { border-color: #777 !important; color: #ddd !important; }
|
||||
.vv-find-x { color: #555; cursor: pointer; font-size: 16px; line-height: 1;
|
||||
padding: 0 2px; flex-shrink: 0; }
|
||||
.vv-find-x:hover { color: #aaa; }
|
||||
|
||||
/* Editor status bar */
|
||||
#vv-editor-status { display: none; align-items: center; gap: 10px; padding: 3px 10px;
|
||||
background: #0e0e0e; border: 1px solid #2e2e2e; border-top: none;
|
||||
border-radius: 0 0 4px 4px; font-family: monospace;
|
||||
font-size: 10px; color: #444; user-select: none; flex-shrink: 0; }
|
||||
#vv-editor-status.vv-ed-active { display: flex; }
|
||||
.vv-es-pos { color: #666; }
|
||||
.vv-es-sel { color: #555; }
|
||||
.vv-es-lang { margin-left: auto; color: #4a6a7a; }
|
||||
/* Status bar action buttons */
|
||||
.vv-es-btn { background: none; border: none; cursor: pointer; color: #555;
|
||||
font-size: 10px; padding: 1px 4px; border-radius: 2px; font-family: monospace; }
|
||||
.vv-es-btn:hover { color: #aaa; background: rgba(255,255,255,0.05); }
|
||||
.vv-es-btn.active { color: #6495ed; }
|
||||
#vv-es-fontsize { color: #444; font-size: 10px; min-width: 26px; text-align: center; }
|
||||
|
||||
/* Word wrap mode — applied to #vv-editor-wrap when wrap is enabled */
|
||||
#vv-editor-wrap.vv-ed-wrap .vv-editor-body { white-space: pre-wrap !important; overflow-x: hidden !important; }
|
||||
#vv-editor-wrap.vv-ed-wrap #vv-hl-overlay { white-space: pre-wrap !important; word-break: break-word !important; }
|
||||
#vv-editor-wrap.vv-ed-wrap #vv-ln-gutter { display: none; }
|
||||
#vv-editor-wrap.vv-ed-wrap #vv-cur-line { display: none !important; }
|
||||
|
||||
/* Indent guides — 1px lines every 2 chars, starting at indent level 1 */
|
||||
#vv-editor-inner {
|
||||
position: relative; flex: 1; overflow: hidden;
|
||||
background-color: #0d0d0d;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.045) 0 1px,
|
||||
transparent 1px 2ch
|
||||
);
|
||||
background-position: calc(12px + 2ch) 0;
|
||||
}
|
||||
|
||||
/* Go to line bar */
|
||||
#vv-goto-bar { display: none; align-items: center; gap: 8px; padding: 5px 8px;
|
||||
margin-bottom: 5px; background: #161616; border: 1px solid #333;
|
||||
border-radius: 4px; font-size: 12px; }
|
||||
#vv-goto-bar.vv-goto-open { display: flex; }
|
||||
#vv-goto-input { width: 68px; background: #0d0d0d; border: 1px solid #333; color: #ddd;
|
||||
padding: 4px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; }
|
||||
#vv-goto-input:focus { border-color: #6495ed; outline: none; }
|
||||
.vv-goto-info { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* Undo / Redo buttons */
|
||||
.vv-undo-redo-btn { border-color: #444 !important; color: #666 !important; }
|
||||
.vv-undo-redo-btn:not([disabled]):hover { border-color: #777 !important; color: #ccc !important; background: #1e1e1e !important; }
|
||||
.vv-undo-redo-btn[disabled] { opacity: 0.28 !important; cursor: default !important; pointer-events: none; }
|
||||
.vv-undo-count { font-size: 10px; opacity: 0.7; margin-left: 2px; }
|
||||
|
||||
/* Suggestions panel — accordion */
|
||||
#vv-suggestions { overflow-y: auto; }
|
||||
.vv-sug-block { border-bottom: 1px solid #1e1e1e; }
|
||||
.vv-sug-header { display: flex; align-items: center; gap: 8px; padding: 7px 4px;
|
||||
cursor: pointer; user-select: none; flex-wrap: wrap; }
|
||||
.vv-sug-header:hover { background: rgba(255,255,255,0.03); }
|
||||
.vv-sug-chevron { color: #555; font-size: 11px; flex-shrink: 0; width: 10px; }
|
||||
.vv-sug-title { color: #7a9eb5; font-weight: bold; font-size: 13px; flex: 1; min-width: 120px; }
|
||||
.vv-sug-cron { color: #ddd; background: #111; border: 1px solid #333; padding: 1px 6px;
|
||||
border-radius: 3px; font-size: 11px; font-family: monospace; white-space: nowrap; flex-shrink: 0; }
|
||||
.vv-sug-label { color: #666; font-size: 12px; flex: 1; min-width: 0; white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-sug-status { font-size: 11px; flex-shrink: 0; margin-left: auto; }
|
||||
.vv-sug-on { color: #4caf50; }
|
||||
.vv-sug-off { color: #777; }
|
||||
.vv-sug-none { color: #444; }
|
||||
.vv-sug-body { padding: 0 14px 10px; }
|
||||
.vv-sug-desc { font-size: 12px; color: #888; white-space: pre-wrap; word-break: break-word;
|
||||
background: none; border: none; margin: 4px 0 8px; padding: 0;
|
||||
font-family: inherit; line-height: 1.6; }
|
||||
.vv-sug-scripts { display: flex; flex-direction: column; gap: 4px; }
|
||||
.vv-sug-script-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.vv-sug-inline-cron { color: #aaa; background: #111; padding: 1px 5px; border-radius: 3px;
|
||||
font-size: 11px; white-space: nowrap; }
|
||||
.vv-sug-path { color: #888; font-family: monospace; }
|
||||
.vv-sug-configured { color: #4caf50; font-size: 11px; }
|
||||
/* Info sections inside Scheduler Information panel */
|
||||
.vv-info-block .vv-sug-title { color: #9ab; }
|
||||
.vv-info-body { padding: 2px 10px 10px; }
|
||||
.vv-info-cols { margin: 0; padding-left: 16px; columns: 2; column-gap: 20px; column-fill: balance; }
|
||||
.vv-info-cols li { font-size: 12px; color: #aaa; margin-bottom: 5px; break-inside: avoid; line-height: 1.5; }
|
||||
.vv-info-cols .vv-info-sep { column-span: all; list-style: none; margin: 10px -4px 6px;
|
||||
padding: 4px 8px; background: #161a1d; border-left: 2px solid #1e6fa5;
|
||||
font-size: 10px; font-weight: bold; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: #4a8ab5; break-inside: avoid; }
|
||||
.vv-info-cols li strong { color: #ccc; }
|
||||
.vv-info-cols code { background: #1a1a1a; padding: 0 4px; border-radius: 2px;
|
||||
font-size: 11px; color: #9ab; border: 1px solid #333; }
|
||||
.vv-info-divider { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #444;
|
||||
padding: 10px 12px 4px; border-top: 1px solid #2a2a2a; margin-top: 2px; }
|
||||
|
||||
/* How do I use this — pinned at top of suggestions panel */
|
||||
#vv-how-to-use {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
background: #1e1e1e;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
/* Run-status dot colours (tree + activity list) */
|
||||
.vv-stat-ok { color: #4caf50; }
|
||||
.vv-stat-warn { color: #ff9800; }
|
||||
.vv-stat-error{ color: #f44336; }
|
||||
.vv-stat-skip { color: #607d8b; }
|
||||
.vv-stat-none { color: #444; }
|
||||
|
||||
/* Cron humanizer hint — left of cron input; first to collapse under space pressure */
|
||||
.vv-cron-hint { font-size: 10px; color: #505050; white-space: nowrap;
|
||||
flex: 0 10 auto; min-width: 0; max-width: 130px;
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
pointer-events: none; user-select: none; }
|
||||
|
||||
/* Recent Activity list */
|
||||
.vv-activity-list { display: flex; flex-direction: column; }
|
||||
.vv-activity-row { display: flex; align-items: center; gap: 6px; padding: 4px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 11px; cursor: pointer; }
|
||||
.vv-activity-row:last-child { border-bottom: none; }
|
||||
.vv-activity-row:hover .vv-activity-label { color: #fff; }
|
||||
.vv-activity-dot { flex-shrink: 0; font-size: 9px; }
|
||||
.vv-activity-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; color: #ccc; }
|
||||
.vv-activity-ago { color: #555; font-size: 10px; white-space: nowrap; flex-shrink: 0; }
|
||||
.vv-activity-dur { color: #444; font-size: 10px; white-space: nowrap; flex-shrink: 0;
|
||||
min-width: 32px; text-align: right; }
|
||||
|
||||
/* Log search */
|
||||
#vv-log-search { width: 120px; font-size: 11px; padding: 2px 6px;
|
||||
background: #111; border: 1px solid #333; color: #ccc;
|
||||
border-radius: 3px; font-family: monospace; }
|
||||
#vv-log-search:focus { border-color: #555; outline: none; }
|
||||
mark { background: #5d4037; color: #ffcc80; border-radius: 2px; }
|
||||
.vv-log-dim { opacity: 0.2; }
|
||||
|
||||
/* Cron Calculator */
|
||||
.vv-calc-wrap { display: flex; flex-direction: column; gap: 8px; }
|
||||
.vv-calc-in { width: 100%; background: #111; border: 1px solid #333; color: #ccc;
|
||||
padding: 5px 8px; border-radius: 3px; font-family: monospace; font-size: 12px;
|
||||
box-sizing: border-box; }
|
||||
.vv-calc-in:focus { border-color: #555; outline: none; }
|
||||
.vv-calc-expr { font-family: monospace; font-size: 13px; color: #7cb8e8;
|
||||
background: #0d1117; padding: 4px 8px; border-radius: 3px; }
|
||||
.vv-calc-desc { font-size: 12px; color: #aaa; padding: 2px 2px 0; }
|
||||
.vv-calc-hint { font-size: 11px; color: #555; font-style: italic; }
|
||||
.vv-calc-runs-lbl { font-size: 10px; color: #555; text-transform: uppercase;
|
||||
letter-spacing: .05em; margin-top: 4px; }
|
||||
.vv-calc-runs { display: flex; flex-direction: column; gap: 2px; }
|
||||
.vv-calc-run-row { display: flex; gap: 8px; font-size: 11px; }
|
||||
.vv-calc-run-in { color: #4caf50; white-space: nowrap; min-width: 56px; }
|
||||
.vv-calc-run-at { color: #666; }
|
||||
.vv-calc-apply-btn { align-self: flex-start; margin-top: 2px; }
|
||||
|
||||
/* Board blocks — Next Runs, Errors, Locks, Partner, Disabled */
|
||||
.vv-board-placeholder { color: #555; font-size: 11px; padding: 3px 0; }
|
||||
|
||||
.vv-hdr-badge { display: inline-block; padding: 1px 7px; border-radius: 10px;
|
||||
font-size: 10px; font-weight: bold; flex-shrink: 0; }
|
||||
.vv-hdr-badge-red { background: #7f0000; color: #ef9a9a; }
|
||||
.vv-hdr-badge-orange { background: #5d2000; color: #ffcc80; }
|
||||
.vv-hdr-badge-gray { background: #2a2a2a; color: #aaa; }
|
||||
|
||||
/* Next Runs */
|
||||
.vv-nextrun-list { display: flex; flex-direction: column; }
|
||||
.vv-nextrun-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-nextrun-row:last-child { border-bottom: none; }
|
||||
.vv-nr-label { color: #ccc; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vv-nr-cron { color: #555; font-family: monospace; font-size: 10px; flex-shrink: 0; }
|
||||
.vv-nr-in { color: #4caf50; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
|
||||
.vv-nr-at { color: #666; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* Errors */
|
||||
.vv-errors-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.vv-err-row { padding: 5px 0; border-bottom: 1px solid #1a1a1a; }
|
||||
.vv-err-row:last-child { border-bottom: none; }
|
||||
.vv-err-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; }
|
||||
.vv-err-script { color: #e07070; font-size: 11px; font-weight: bold; }
|
||||
.vv-err-age { color: #555; font-size: 10px; flex: 1; padding-left: 6px; white-space: nowrap; }
|
||||
.vv-ack-btn { margin-left: auto; font-size: 10px; padding: 1px 6px;
|
||||
color: #555; border-color: #333; flex-shrink: 0; }
|
||||
.vv-ack-btn:hover { color: #aaa; border-color: #555; }
|
||||
.vv-err-line { color: #888; font-size: 11px; word-break: break-all; line-height: 1.4; }
|
||||
|
||||
/* Locks */
|
||||
.vv-locks-list { display: flex; flex-direction: column; }
|
||||
.vv-lock-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-lock-row:last-child { border-bottom: none; }
|
||||
.vv-lk-name { color: #e8a87c; flex: 1; }
|
||||
.vv-lk-age { color: #777; font-size: 11px; white-space: nowrap; }
|
||||
.vv-lock-clear { font-size: 11px !important; padding: 1px 7px !important;
|
||||
border-color: #b71c1c !important; color: #ef9a9a !important; }
|
||||
.vv-lock-clear:hover { background: #7f0000 !important; color: #fff !important; }
|
||||
|
||||
/* Partner */
|
||||
.vv-partner-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
|
||||
.vv-partner-name { color: #ccc; }
|
||||
.vv-partner-detail { color: #666; font-size: 11px; }
|
||||
.vv-partner-down { color: #f44336 !important; }
|
||||
|
||||
/* Disabled scripts */
|
||||
.vv-disabled-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.vv-disabled-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-disabled-row:last-child { border-bottom: none; }
|
||||
.vv-disabled-name { color: #888; flex: 1; }
|
||||
.vv-disabled-grp { color: #444; font-size: 10px; font-family: monospace; white-space: nowrap; }
|
||||
|
||||
/* Notification board */
|
||||
.vv-nb-board { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 7px 10px; background: #141414; border-bottom: 1px solid #222;
|
||||
font-size: 12px; min-height: 34px; }
|
||||
.vv-nb-stat { color: #aaa; }
|
||||
.vv-nb-sep { color: #444; }
|
||||
.vv-nb-running { color: #f0a040; }
|
||||
.vv-nb-conf-btns { display: flex; gap: 6px; margin-left: auto; }
|
||||
.vv-nb-conf-btn { font-size: 11px; font-family: monospace; }
|
||||
|
||||
/* Advanced mode button */
|
||||
.vv-adv-mode-btn { border: 1px solid #555; color: #999; transition: background 0.15s, color 0.15s, border-color 0.15s; }
|
||||
.vv-adv-mode-btn:hover { border-color: #888; color: #fff; }
|
||||
.vv-adv-mode-btn.vv-adv-mode-on { background: #1565c0; border-color: #1565c0; color: #fff; }
|
||||
|
||||
/* Script browser in suggestions panel */
|
||||
.vv-sb-desc { font-size: 12px; color: #888; margin: 4px 0 6px; line-height: 1.5; }
|
||||
.vv-sb-child-desc { margin-left: 16px; }
|
||||
.vv-sb-hdr { font-family: monospace; font-size: 11px; color: #666; white-space: pre-wrap;
|
||||
word-break: break-word; background: none; border: none; margin: 4px 0 8px;
|
||||
padding: 0; line-height: 1.5; border-left: 2px solid #222; padding-left: 8px; }
|
||||
.vv-sb-full { font-family: monospace; font-size: 11px; color: #888; white-space: pre-wrap;
|
||||
word-break: break-word; background: #0a0a0a; border: 1px solid #222;
|
||||
border-radius: 3px; margin: 6px 0 8px; padding: 8px 10px; line-height: 1.5;
|
||||
max-height: 480px; overflow-y: auto; }
|
||||
.vv-sb-child-block { border-top: 1px solid #1a1a1a; margin-top: 8px; padding-top: 8px; }
|
||||
.vv-sb-child-name { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; flex-wrap: wrap; }
|
||||
|
||||
/* README content display */
|
||||
.vv-readme-body { font-family: monospace; font-size: 11px; color: #777; white-space: pre-wrap;
|
||||
word-break: break-word; background: none; border: none; margin: 0;
|
||||
padding: 0 4px; line-height: 1.6; }
|
||||
|
||||
/* Script browser tree rows */
|
||||
#vv-sb-tree { padding: 0; }
|
||||
.vv-sb-entry { }
|
||||
.vv-sb-row { display: flex; align-items: center; gap: 6px; padding: 5px 6px;
|
||||
cursor: pointer; border-radius: 3px; user-select: none;
|
||||
transition: background 0.1s, box-shadow 0.1s; }
|
||||
.vv-sb-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.vv-sb-selected { background: rgba(100,149,237,0.14) !important;
|
||||
outline: 1px solid rgba(100,149,237,0.4);
|
||||
box-shadow: inset 3px 0 0 #6495ed; }
|
||||
.vv-sb-orch-row { border-bottom: 1px solid #1c1c1c; }
|
||||
.vv-sb-child-row { padding-left: 2px; }
|
||||
.vv-sb-expand { width: 14px; flex-shrink: 0; color: #555; font-size: 10px; text-align: center; }
|
||||
.vv-sb-expand:hover { color: #aaa; }
|
||||
.vv-sb-leaf { cursor: default; pointer-events: none; }
|
||||
.vv-sb-name { flex: 1; font-size: 13px; color: #b0c4d0; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-sb-orch-row .vv-sb-name { font-weight: bold; color: #9ab; }
|
||||
.vv-sb-children { padding-left: 10px; }
|
||||
.vv-sb-child-indent { width: 14px; flex-shrink: 0; color: #333; font-size: 11px;
|
||||
text-align: center; pointer-events: none; }
|
||||
.vv-sb-badge { flex-shrink: 0; font-size: 11px; }
|
||||
.vv-sb-cron { color: #555; background: #111; border: 1px solid #222;
|
||||
padding: 0 4px; border-radius: 2px; font-size: 10px;
|
||||
font-family: monospace; flex-shrink: 0; }
|
||||
.vv-sb-status { font-size: 10px; flex-shrink: 0; }
|
||||
|
||||
/* Script info content area */
|
||||
.vv-si-hdr { font-family: monospace; font-size: 12px; color: #4d894d; white-space: pre-wrap;
|
||||
word-break: break-word; background: none; border: none; margin: 0;
|
||||
padding: 8px 12px; line-height: 1.6; }
|
||||
.vv-si-src { font-family: monospace; font-size: 11px; white-space: pre-wrap;
|
||||
word-break: break-word; background: none; border: none; margin: 0;
|
||||
padding: 8px 12px; line-height: 1.5; }
|
||||
|
||||
/* Cog settings button next to script name */
|
||||
.vv-cog-btn { cursor:pointer; color:#444; font-size:.85em; margin-right:4px;
|
||||
line-height:1; vertical-align:middle; user-select:none; }
|
||||
.vv-cog-btn:hover { color:#aaa; }
|
||||
|
||||
/* Enriched script info blocks (advanced mode: header + docs + config) */
|
||||
.vv-sinfo-block { border-top: 1px solid #222; padding: 0; }
|
||||
.vv-sinfo-block:first-child { border-top: none; }
|
||||
.vv-sinfo-lbl { font-size: 10px; font-weight: bold; text-transform: uppercase;
|
||||
letter-spacing: .06em; color: #555; padding: 6px 12px 2px; }
|
||||
|
||||
/* Syntax highlight tokens — dark theme (VSCode-inspired) */
|
||||
.vv-hl-sep { color: #2d2d2d; }
|
||||
.vv-hl-shebang { color: #4a4a4a; }
|
||||
.vv-hl-hash { color: #4a7340; }
|
||||
.vv-hl-comment { color: #6a9955; }
|
||||
.vv-hl-section { color: #9cdcfe; font-weight: bold; letter-spacing: 0.04em; }
|
||||
.vv-hl-key { color: #9cdcfe; }
|
||||
.vv-hl-value { color: #ce9178; }
|
||||
.vv-hl-cron { color: #d7ba7d; font-weight: bold; }
|
||||
.vv-hl-text { color: #4d894d; }
|
||||
.vv-hl-keyword { color: #569cd6; }
|
||||
.vv-hl-builtin { color: #4ec9b0; }
|
||||
.vv-hl-string { color: #ce9178; }
|
||||
.vv-hl-var { color: #d7ba7d; }
|
||||
.vv-hl-number { color: #b5cea8; }
|
||||
.vv-hl-op { color: #808080; }
|
||||
|
||||
/* Log panel */
|
||||
.vv-log-panel { margin-top: 10px; border-top: 1px solid #333; padding-top: 8px; }
|
||||
.vv-log-toolbar { display: flex; justify-content: space-between; align-items: center;
|
||||
flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
|
||||
.vv-log-ts { font-size: 11px; color: #666; }
|
||||
.vv-log-pre { background: #0d0d0d; border: 1px solid #333; border-radius: 4px;
|
||||
padding: 10px 12px; margin: 0; font-family: monospace; font-size: 12px;
|
||||
color: #ccc; white-space: pre-wrap; word-break: break-all;
|
||||
max-height: 340px; overflow-y: auto; line-height: 1.5;
|
||||
scroll-behavior: auto; }
|
||||
|
||||
/* Toggle switch */
|
||||
.vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
|
||||
.vv-toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.vv-slider { position: absolute; inset: 0; background: #444; border-radius: 20px; cursor: pointer;
|
||||
transition: 0.2s; }
|
||||
.vv-slider:before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; bottom: 3px;
|
||||
background: #fff; border-radius: 50%; transition: 0.2s; }
|
||||
.vv-toggle input:checked + .vv-slider { background: #4caf50; }
|
||||
.vv-toggle input:checked + .vv-slider:before { transform: translateX(16px); }
|
||||
|
||||
/* Config editor */
|
||||
#vv-conf-tabs { display: flex; gap: 4px; margin-bottom: 8px; }
|
||||
.vv-conf-tab { padding: 4px 12px; text-decoration: none; color: #aaa;
|
||||
border: 1px solid #444; border-radius: 4px; font-size: 13px; }
|
||||
.vv-conf-tab.active { color: #fff; background: #333; border-color: #666; }
|
||||
#vv-conf-editor { width: 100%; min-height: 500px; background: #111; color: #ddd;
|
||||
border: 1px solid #444; padding: 12px; font-family: monospace;
|
||||
font-size: 13px; line-height: 1.5; border-radius: 4px; box-sizing: border-box; resize: vertical; }
|
||||
#vv-conf-actions { margin-top: 8px; display: flex; align-items: center; gap: 10px; }
|
||||
#vv-conf-actions button { padding: 6px 18px; background: #4caf50; border: none;
|
||||
color: #fff; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
#vv-conf-actions button:hover { background: #388e3c; }
|
||||
#vv-conf-status { font-size: 13px; color: #aaa; }
|
||||
|
||||
/* Docs */
|
||||
#vv-docs { display: flex; gap: 16px; }
|
||||
#vv-docs-sidebar { width: 220px; flex-shrink: 0; }
|
||||
#vv-docs-sidebar h3 { font-size: 12px; text-transform: uppercase; color: #888; margin: 0 0 8px; }
|
||||
#vv-docs-sidebar ul { list-style: none; padding: 0; margin: 0; }
|
||||
#vv-docs-sidebar li { margin: 2px 0; }
|
||||
#vv-docs-sidebar a { display: block; padding: 3px 8px; font-size: 12px; color: #aaa;
|
||||
text-decoration: none; border-radius: 3px; }
|
||||
#vv-docs-sidebar a:hover { background: #222; color: #fff; }
|
||||
#vv-docs-sidebar a.active { background: #333; color: #fff; }
|
||||
#vv-docs-content { flex: 1; min-width: 0; }
|
||||
.vv-doc-body { background: #1a1a1a; border: 1px solid #444; border-radius: 6px;
|
||||
padding: 20px; line-height: 1.7; }
|
||||
.vv-doc-body h1, .vv-doc-body h2, .vv-doc-body h3 { color: #ddd; }
|
||||
.vv-doc-body code { background: #111; padding: 1px 5px; border-radius: 3px; font-size: 12px; }
|
||||
.vv-doc-body pre { background: #111; padding: 12px; border-radius: 4px; overflow-x: auto; }
|
||||
.vv-doc-hint { font-size: 12px; color: #666; margin-top: 8px; }
|
||||
|
||||
/* Media stream sessions */
|
||||
.vv-stream-servers { display: flex; justify-content: space-between; align-items: center;
|
||||
flex-wrap: nowrap; gap: 8px; margin-bottom: 10px; }
|
||||
/* Left side: server badges + media type */
|
||||
.vv-stream-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
/* Right side: device / resolution / codec chip groups */
|
||||
.vv-stream-right { display: flex; align-items: center; gap: 0; flex-wrap: nowrap; flex-shrink: 0; }
|
||||
.vv-server-badge { background: #333; border: 1px solid #555; color: #aaa;
|
||||
font-size: 10px; padding: 1px 7px; border-radius: 10px; }
|
||||
.vv-server-badge-sm { font-size: 9px; padding: 1px 6px; flex-shrink: 0; }
|
||||
/* Chip groups — server badge row */
|
||||
.vv-device-sep { color: #2a2a2a; font-size: 12px; margin: 0 2px; user-select: none; }
|
||||
/* shared chip geometry */
|
||||
.vv-device-chip,
|
||||
.vv-chip-device, .vv-chip-res, .vv-chip-codec, .vv-chip-mtype {
|
||||
font-size: 10px; padding: 1px 7px; border-radius: 10px;
|
||||
white-space: nowrap; border: 1px solid; }
|
||||
/* media type — purple */
|
||||
.vv-chip-mtype { color: #9a6abf; background: #1a0d2e; border-color: #3a1e55; }
|
||||
/* device — blue */
|
||||
.vv-chip-device { color: #5a9ec8; background: #0d1e2e; border-color: #1c3f5c; }
|
||||
/* resolution — teal */
|
||||
.vv-chip-res { color: #4aaa9a; background: #0b1e1c; border-color: #1a4540; }
|
||||
/* codec — amber */
|
||||
.vv-chip-codec { color: #b38820; background: #201500; border-color: #483300; }
|
||||
/* legacy fallback */
|
||||
.vv-device-chip { color: #666; background: #181818; border-color: #252525; }
|
||||
/* chip group wrappers — inline so they flow with the badge row */
|
||||
.vv-chip-group-device, .vv-chip-group-res, .vv-chip-group-codec { display: contents; }
|
||||
.vv-stream-empty { color: #555; font-style: italic; font-size: 12px; margin: 4px 0; }
|
||||
.vv-stream-empty span { font-size: 11px; color: #444; }
|
||||
.vv-stream-row { margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #282828; }
|
||||
.vv-stream-row:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
|
||||
.vv-stream-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.vv-stream-icon { font-size: 10px; color: #888; flex-shrink: 0; }
|
||||
.vv-stream-title { flex: 1; font-size: 12px; color: #ddd; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-stream-meta { display: flex; gap: 10px; flex-wrap: wrap; font-size: 10px;
|
||||
color: #666; margin-bottom: 5px; }
|
||||
.vv-stream-user { color: #888; }
|
||||
.vv-stream-client { color: #555; }
|
||||
.vv-stream-method { font-weight: 500; }
|
||||
.vv-stream-time { color: #555; margin-left: auto; }
|
||||
.vv-stream-bar { height: 3px; background: #1a1a1a; border-radius: 2px; overflow: hidden; }
|
||||
.vv-stream-bar div { height: 100%; border-radius: 2px; transition: width 0.4s; }
|
||||
|
||||
/* Live var substitution colours */
|
||||
code.vv-live-var { color: #4caf50; background: #0d1f0d; }
|
||||
code.vv-unknown-var { color: #ff9800; background: #1f130d; }
|
||||
|
||||
/* ── Arrange mode ─────────────────────────────────────────────────────────── */
|
||||
.vv-drag-handle { cursor: grab; color: #555; font-size: 14px; padding: 0 5px 0 0; user-select: none; flex-shrink: 0; }
|
||||
.vv-drag-handle:hover { color: #888; }
|
||||
.vv-drag-handle:active { cursor: grabbing; color: #aaa; }
|
||||
/* Dragged item: semi-transparent with a dashed ring so you still see what you grabbed */
|
||||
.vv-drag-ghost { opacity: 0.4; outline: 1px dashed #555; border-radius: 4px; }
|
||||
/* Drop insertion line: blue glow to match selection accent */
|
||||
.vv-drop-line { height: 2px; background: #6495ed; border-radius: 2px; margin: 2px 0;
|
||||
pointer-events: none; box-shadow: 0 0 6px rgba(100,149,237,0.7); }
|
||||
.vv-arrange-active .vv-children { min-height: 28px; border: 1px dashed #2a2a2a; border-radius: 4px;
|
||||
padding: 4px 2px; transition: border-color 0.12s, background 0.12s; }
|
||||
/* Drop targets use blue to match selection theme */
|
||||
.vv-arrange-active .vv-children.vv-drop-target { border-color: #6495ed; background: rgba(100,149,237,0.07); }
|
||||
.vv-arrange-btn-active { background: #1a3a1e !important; color: #4caf50 !important; border-color: #2d5c33 !important; }
|
||||
/* Grabbing cursor while dragging anything */
|
||||
.vv-is-dragging, .vv-is-dragging * { cursor: grabbing !important; }
|
||||
.vv-arrange-save-btn { background: #1a3a1e; border-color: #2d5c33; color: #4caf50; }
|
||||
.vv-arrange-save-btn:hover { background: #22502a; }
|
||||
#vv-arrange-btn { background: #7b1fa2; border-color: #7b1fa2; }
|
||||
#vv-arrange-btn:hover { background: #4a148c; border-color: #4a148c; }
|
||||
|
||||
/* ── Arrange workspace panel ─────────────────────────────────────────────── */
|
||||
.vv-arrange-ws-hdr { font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase;
|
||||
letter-spacing: 0.6px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
|
||||
#vv-pending-badge { background: #ff9800; color: #000; font-size: 10px; padding: 1px 7px;
|
||||
border-radius: 10px; font-weight: bold; }
|
||||
.vv-arrange-pending-hdr { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
margin-bottom: 5px; }
|
||||
.vv-pending-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
||||
font-size: 11px; border-bottom: 1px solid #1e1e1e; }
|
||||
.vv-pending-script { color: #ddd; font-weight: 500; }
|
||||
.vv-pending-arrow { color: #555; font-size: 10px; }
|
||||
.vv-library-zone { border: 1px dashed #2a2a2a; border-radius: 4px; padding: 6px;
|
||||
min-height: 60px; transition: border-color 0.12s, background 0.12s; }
|
||||
.vv-library-zone.vv-drop-target { border-color: #ef5350; background: rgba(239,83,80,0.09);
|
||||
box-shadow: inset 0 0 8px rgba(239,83,80,0.08); }
|
||||
.vv-arrange-drop-hint { font-size: 10px; color: #444; text-align: center; padding: 2px 0 7px; }
|
||||
.vv-lib-card { background: #1c1c1c; border: 1px solid #2e2e2e; border-radius: 3px;
|
||||
padding: 4px 8px; margin-bottom: 4px; cursor: grab; display: flex;
|
||||
align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.vv-lib-card:hover { border-color: #444; }
|
||||
.vv-lib-card.vv-drag-ghost { opacity: 0.35; }
|
||||
.vv-lib-card-name { font-size: 16px; color: #ccc; font-weight: bold; }
|
||||
.vv-lib-card-path { font-size: 10px; color: #444; font-family: monospace; }
|
||||
|
||||
/* ── Custom script folders ────────────────────────────────────────────────── */
|
||||
.vv-folder-group { margin-bottom: 1px; }
|
||||
.vv-folder-row { display: flex; align-items: center; gap: 6px; padding: 3px 6px;
|
||||
cursor: pointer; border-radius: 3px; color: #888; font-size: 12px; user-select: none; }
|
||||
.vv-folder-row:hover { background: #1e1e1e; }
|
||||
.vv-folder-chevron { font-size: 10px; color: #555; width: 10px; flex-shrink: 0; }
|
||||
.vv-folder-name { flex: 1; font-weight: 500; color: #aaa; }
|
||||
.vv-folder-count { font-size: 10px; color: #555; background: #1c1c1c;
|
||||
padding: 0 5px; border-radius: 8px; border: 1px solid #2a2a2a; }
|
||||
.vv-folder-children { padding-left: 12px; min-height: 4px; }
|
||||
.vv-folder-children.vv-drop-target { background: rgba(100,149,237,0.07); border-radius: 4px;
|
||||
outline: 1px dashed rgba(100,149,237,0.4); }
|
||||
.vv-folder-new-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; }
|
||||
.vv-new-folder-btn { background: #0277bd !important; border: none !important; color: #fff !important; }
|
||||
.vv-new-folder-btn:hover { background: #01579b !important; }
|
||||
|
||||
/* ── Snapshot footer (right panel status bar) ────────────────────────────── */
|
||||
.vv-snap-footer { display: flex !important; flex-direction: row !important;
|
||||
align-items: center !important; justify-content: center; gap: 20px; flex-wrap: wrap; }
|
||||
.vv-snap-item { display: flex; align-items: center; gap: 7px; }
|
||||
.vv-snap-label { font-size: 12px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.vv-snap-bar { width: 90px; height: 8px; background: #222; border-radius: 4px; overflow: hidden; flex-shrink: 0; }
|
||||
.vv-snap-bar span { display: block; height: 100%; border-radius: 4px; width: 0;
|
||||
transition: width 0.5s, background-color 0.5s; }
|
||||
.vv-snap-val { font-size: 15px; color: #aaa; font-family: monospace; min-width: 36px; }
|
||||
.vv-snap-div { color: #333; font-size: 15px; }
|
||||
.vv-snap-state { font-size: 15px; font-weight: bold; }
|
||||
#vv-snap-partner { font-size: 15px; color: #666; }
|
||||
.vv-snap-media { font-size: 15px; color: #666; }
|
||||
|
||||
/* ── Containers and VMs card ──────────────────────────────────────────────── */
|
||||
.vv-df-section-hdr { font-size: 10px; font-weight: bold; color: #555; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; padding: 4px 2px 5px; border-bottom: 1px solid #222;
|
||||
margin-bottom: 4px; }
|
||||
.vv-df-empty { font-size: 12px; color: #555; font-style: italic; padding: 4px 2px 8px; }
|
||||
.vv-df-vm-row { display: flex; align-items: center; gap: 8px; padding: 5px 2px;
|
||||
border-bottom: 1px solid #1a1a1a; }
|
||||
.vv-df-vm-row:last-of-type { border-bottom: none; }
|
||||
.vv-df-vm-icon { font-size: 15px; line-height: 1; flex-shrink: 0; }
|
||||
.vv-df-vm-meta { font-size: 10px; color: #555; }
|
||||
.vv-df-cols { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.vv-df-col { flex: 1; min-width: 0; }
|
||||
#vv-docker-folders-body { overflow-x: hidden; }
|
||||
.vv-df-folder { border-bottom: 1px solid #1a1a1a; }
|
||||
.vv-df-folder:last-child { border-bottom: none; }
|
||||
.vv-df-folder-hdr { display: flex; align-items: center; gap: 6px; padding: 5px 4px;
|
||||
cursor: pointer; user-select: none; border-radius: 3px; }
|
||||
.vv-df-folder-hdr:hover { background: rgba(255,255,255,0.03); }
|
||||
.vv-df-chevron { color: #555; font-size: 10px; width: 10px; flex-shrink: 0; }
|
||||
.vv-df-fname { flex: 1; font-size: 12px; color: #aaa; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-df-folder-body { padding-left: 10px; padding-bottom: 3px; }
|
||||
.vv-df-container { display: flex; align-items: center; gap: 7px; padding: 3px 6px;
|
||||
cursor: pointer; border-radius: 3px; user-select: none; }
|
||||
.vv-df-container:hover { background: rgba(255,255,255,0.04); }
|
||||
.vv-df-active { background: rgba(100,149,237,0.1) !important;
|
||||
outline: 1px solid rgba(100,149,237,0.35);
|
||||
box-shadow: inset 3px 0 0 #6495ed; }
|
||||
.vv-df-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.vv-df-cname { flex: 1; font-size: 12px; color: #ccc;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-df-status { font-size: 10px; color: #555; flex-shrink: 0; white-space: nowrap; }
|
||||
.vv-df-actions { display: flex; gap: 6px; padding: 3px 6px 5px 24px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Rsync standalone controls ────────────────────────────────────────────── */
|
||||
.vv-rsync-location { width: 120px; flex-shrink: 1; min-width: 60px; font-size: 11px; font-family: monospace;
|
||||
background: #111; border: 1px solid #333; color: #aaa;
|
||||
padding: 2px 6px; border-radius: 3px; }
|
||||
.vv-rsync-location:focus { border-color: #555; outline: none; }
|
||||
.vv-rsync-location::placeholder { color: #444; }
|
||||
.vv-script-args { flex: 1 1 0; min-width: 60px; max-width: 180px; font-size: 11px; font-family: monospace;
|
||||
background: #111; border: 1px solid #2a2a2a; color: #aaa;
|
||||
padding: 2px 6px; border-radius: 3px;
|
||||
opacity: 0.3; transition: opacity 0.15s; }
|
||||
.vv-script-args:hover, .vv-script-args:focus { opacity: 1; border-color: #555; outline: none; }
|
||||
.vv-script-args::placeholder { color: #3a3a3a; }
|
||||
.vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; }
|
||||
.vv-rsync-save-btn:hover { background: #22382a; }
|
||||
@@ -0,0 +1,946 @@
|
||||
/* Varaverk plugin styles — inherits unRAID theme, adds plugin-specific layout */
|
||||
|
||||
#varaverk-wrap { padding: 10px; font-family: inherit; }
|
||||
|
||||
/* Tab bar */
|
||||
#vv-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 2px solid #444; align-items: flex-end; }
|
||||
.vv-tab { padding: 6px 16px; text-decoration: none; color: #aaa; border-radius: 4px 4px 0 0; }
|
||||
.vv-tab:hover { color: #fff; background: #333; }
|
||||
.vv-tab.active { color: #fff; background: #555; border-bottom: 2px solid #fff; }
|
||||
|
||||
/* Expand toggle button */
|
||||
#vv-expand-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: #333; font-size: 15px; padding: 2px 8px 4px;
|
||||
line-height: 1; border-radius: 3px; transition: color .15s;
|
||||
margin-left: 6px; flex-shrink: 0;
|
||||
}
|
||||
#vv-expand-btn:hover { color: #888; }
|
||||
#vv-expand-btn.active { color: #aaa; }
|
||||
|
||||
/* Fullscreen mode — hide Unraid chrome, reclaim the space */
|
||||
body.vv-fullscreen #header { display: none !important; }
|
||||
body.vv-fullscreen #menu { display: none !important; }
|
||||
body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5rem !important; }
|
||||
|
||||
/* Cards / layout */
|
||||
.vv-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.vv-card { flex: 1; min-width: 200px; background: #1e1e1e; border: 1px solid #444;
|
||||
border-radius: 6px; padding: 12px; }
|
||||
.vv-wide { flex: 100%; }
|
||||
.vv-card h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase;
|
||||
color: #888; letter-spacing: 0.05em; white-space: normal;
|
||||
overflow: hidden; min-width: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; }
|
||||
|
||||
/* Cog icon — links to related Unraid page. Hidden until card hovered. */
|
||||
.vv-card-cog { color: #2a2a2a; font-size: 13px; line-height: 1; text-decoration: none;
|
||||
padding: 1px 3px; border-radius: 3px; flex-shrink: 0;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
font-style: normal; font-weight: normal; text-transform: none;
|
||||
letter-spacing: 0; cursor: pointer; }
|
||||
.vv-card:hover .vv-card-cog { color: #4a4a4a; }
|
||||
.vv-card-cog:hover { color: #aaa !important; background: #333; }
|
||||
|
||||
/* Card header icon wrapper */
|
||||
.vv-ico { display:inline-flex; align-items:center; opacity:0.38; flex-shrink:0; }
|
||||
|
||||
/* Status accent — left border colour for state-driven cards */
|
||||
.vv-accent-ok { border-left-color: #2a5a2a !important; }
|
||||
.vv-accent-warn { border-left-color: #5a4000 !important; }
|
||||
.vv-accent-err { border-left-color: #5a1e1e !important; }
|
||||
#vv-monitor .vv-card { transition: border-left-color 0.5s; }
|
||||
|
||||
/* Status banner — coloured strip at top of card body */
|
||||
.vv-banner { border-radius: 4px; padding: 5px 10px; margin-bottom: 10px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 12px; font-weight: 600; }
|
||||
.vv-banner-ok { background: #061306; border: 1px solid #1a401a; color: #4caf50; }
|
||||
.vv-banner-warn { background: #130e00; border: 1px solid #3d2e00; color: #ff9800; }
|
||||
.vv-banner-err { background: #140404; border: 1px solid #3d1010; color: #f44336; }
|
||||
.vv-banner-off { background: #0d0d0d; border: 1px solid #252525; color: #555; }
|
||||
|
||||
/* System card — no h3, no top padding waste */
|
||||
#vv-system { padding-top: 14px; }
|
||||
|
||||
/* System action buttons */
|
||||
.vv-sys-btn { background: #2a2a2a; border: 1px solid #e65100; color: #ff9800; border-radius: 3px;
|
||||
padding: 3px 0; font-size: 6px; cursor: pointer; line-height: 1;
|
||||
width: 50px; min-width: 0; text-align: center; }
|
||||
.vv-sys-btn:hover { background: #3a2000; color: #ffb74d; border-color: #ff9800; }
|
||||
|
||||
/* Fallback state badge */
|
||||
.vv-state-badge { font-size: 20px; font-weight: bold; padding: 4px 0; margin-bottom: 2px; }
|
||||
.vv-state-normal { color: #4caf50; }
|
||||
.vv-state-failover { color: #f44336; }
|
||||
.vv-state-no_internet { color: #ff9800; }
|
||||
.vv-state-dark { color: #9e9e9e; }
|
||||
.vv-state-unknown { color: #666; }
|
||||
|
||||
/* Docker table */
|
||||
#vv-docker-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
#vv-docker-table th { text-align: left; padding: 4px 8px; color: #888;
|
||||
border-bottom: 1px solid #444; }
|
||||
#vv-docker-table td { padding: 4px 8px; border-bottom: 1px solid #2a2a2a; }
|
||||
.vv-status-up { color: #4caf50; }
|
||||
.vv-status-down { color: #f44336; }
|
||||
|
||||
/* Scheduler */
|
||||
.vv-hint { color: #888; font-size: 13px; margin-bottom: 16px; }
|
||||
.vv-sched-card { margin-bottom: 10px; }
|
||||
.vv-script { background: #161616; border: 1px solid #333; border-radius: 4px;
|
||||
padding: 6px 10px; margin: 4px 0; margin-left: 20px; }
|
||||
.vv-job-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
transition: background 0.1s, box-shadow 0.1s; }
|
||||
/* Orchestrator top row — clickable, show pointer + hover feedback */
|
||||
.vv-orch-row { cursor: pointer; border-radius: 4px; }
|
||||
.vv-orch-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.vv-job-label { flex: 1; font-size: 16px; font-weight: bold;
|
||||
color: #6fcf97; min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-job-actions { display: flex; align-items: center; gap: 8px; padding-left: 44px; margin-top: 6px; }
|
||||
.vv-job-desc { font-size: 14px; color: #777; margin: 3px 0 2px 0;
|
||||
padding-left: 44px; box-sizing: border-box; width: 100%;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
cursor: default; }
|
||||
.vv-cron { flex: 0 0 80px; width: 80px; background: #111; border: 1px solid #444; color: #ddd;
|
||||
padding: 4px 6px; border-radius: 4px; font-family: monospace; font-size: 11px; }
|
||||
.vv-event-badge { flex: 0 0 auto; padding: 3px 8px; border-radius: 4px; font-size: 12px;
|
||||
background: #1a3a1a; border: 1px solid #2e6b2e; color: #6fcf6f;
|
||||
white-space: nowrap; font-weight: 500; }
|
||||
.vv-flag-badge { flex: 0 0 auto; padding: 2px 6px; border-radius: 3px; font-size: 11px;
|
||||
background: #2e2200; border: 1px solid #6b4e00; color: #d4a017;
|
||||
white-space: nowrap; font-family: monospace; }
|
||||
.vv-log-label { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #888;
|
||||
cursor: pointer; white-space: nowrap; flex-shrink: 0; }
|
||||
.vv-log-label input { cursor: pointer; accent-color: #4caf50; }
|
||||
.vv-log-label:has(input:checked) { color: #4caf50; }
|
||||
.vv-children { padding-top: 8px; border-top: 1px solid #333; margin-top: 8px; }
|
||||
.vv-advanced-toggle { background: none; border: 1px solid #555; color: #aaa;
|
||||
padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
.vv-advanced-toggle:hover { border-color: #888; color: #fff; }
|
||||
.vv-btn-sm { padding: 3px 10px; background: #2a2a2a; border: 1px solid #555; color: #ccc;
|
||||
border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; }
|
||||
.vv-btn-sm:hover { border-color: #888; color: #fff; }
|
||||
.vv-btn-sm.active { border-color: #4caf50; color: #4caf50; }
|
||||
|
||||
/* Save checkmark */
|
||||
.vv-save-check { color: #4caf50; font-size: 13px; width: 14px; flex-shrink: 0;
|
||||
opacity: 0; text-align: center; }
|
||||
@keyframes vv-check-fade { 0%,60% { opacity: 1; } 100% { opacity: 0; } }
|
||||
.vv-save-check.vv-check-show { animation: vv-check-fade 2s forwards; }
|
||||
|
||||
/* Running dot */
|
||||
.vv-job-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.vv-dot-running { background: #4caf50; animation: vv-pulse-dot 1s ease-in-out infinite; }
|
||||
#vv-log-dot { animation: vv-pulse-dot 1s ease-in-out infinite; }
|
||||
@keyframes vv-pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
|
||||
|
||||
/* Selected job row — editor-style left accent + blue tint */
|
||||
.vv-row-selected { background: rgba(100,149,237,0.1); border-radius: 4px;
|
||||
outline: 1px solid rgba(100,149,237,0.35);
|
||||
box-shadow: inset 3px 0 0 #6495ed; }
|
||||
|
||||
/* Two-panel layout */
|
||||
#vv-sched-layout { display: flex; gap: 16px; align-items: stretch; }
|
||||
#vv-sched-left { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-self: flex-start; }
|
||||
#vv-sched-cards { flex: 1; }
|
||||
#vv-sched-right { display: none; flex: 1 1 0; min-width: 0; flex-direction: column; overflow: hidden; align-self: flex-start; }
|
||||
#vv-sched-right.vv-panel-visible { display: flex; }
|
||||
.vv-log-card { flex: 1; display: flex; flex-direction: column; padding-bottom: 0; }
|
||||
.vv-log-right-pre { max-height: none; overflow-y: auto; }
|
||||
|
||||
/* Scheduler stacked layout (narrow viewport) */
|
||||
@media (max-width: 900px) {
|
||||
#vv-sched-layout { flex-direction: column; align-items: stretch; }
|
||||
#vv-sched-left { flex: none; width: 100%; }
|
||||
#vv-sched-right { flex-direction: column; width: 100%; overflow: visible; }
|
||||
.vv-log-right-pre { min-height: 520px; max-height: 680px; }
|
||||
/* Toolbar: stack title row above buttons row, let buttons wrap */
|
||||
.vv-log-toolbar { flex-direction: column; align-items: flex-start; gap: 6px; }
|
||||
.vv-log-toolbar > div { flex-wrap: wrap; gap: 6px !important; }
|
||||
/* Array-event rows: badge already shows the event, cron input is redundant */
|
||||
.vv-job-row:has(.vv-event-badge) .vv-cron { display: none; }
|
||||
}
|
||||
|
||||
/* Plugin settings row (Advanced mode) */
|
||||
.vv-nb-settings { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 5px 10px; background: #101010; border-bottom: 1px solid #1a1a1a; }
|
||||
|
||||
/* ── Monitor: locked row height + scrollable cards ────────────────────────── */
|
||||
|
||||
/* Cards on the monitor grid are flex columns — h3 pins, body scrolls */
|
||||
#vv-monitor .vv-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
#vv-monitor .vv-card h3 { flex-shrink: 0; }
|
||||
#vv-monitor .vv-card > div {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
#vv-monitor .vv-card > div::-webkit-scrollbar { display: none; }
|
||||
/* Hide scrollbars on any nested scrollable div inside monitor cards */
|
||||
#vv-monitor .vv-card div::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Dynamic row heights capped per screen tier — rows size to content, never exceed the cap.
|
||||
minmax(0, Xpx): track is content-driven but capped; align-items:stretch makes all cards
|
||||
in a row fill the track, so short cards (Pools, Watchdog) match tall ones (Array).
|
||||
Breakpoints are viewport height (after browser chrome), not screen height. */
|
||||
|
||||
/* ~720p (viewport ≤ 700px) */
|
||||
@media (min-width: 481px) and (max-height: 700px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 160px) / 4)); }
|
||||
}
|
||||
/* ~1080p (viewport 701–1100px) */
|
||||
@media (min-width: 481px) and (min-height: 701px) and (max-height: 1100px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 240px) / 4)); }
|
||||
}
|
||||
/* ~1440p (viewport 1101–1450px) — calibrated on 15" 1440p display */
|
||||
@media (min-width: 481px) and (min-height: 1101px) and (max-height: 1450px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 335px) / 4)); }
|
||||
}
|
||||
/* ~4K (viewport > 1450px) */
|
||||
@media (min-width: 481px) and (min-height: 1451px) {
|
||||
#vv-monitor { grid-auto-rows: minmax(0, calc((100vh - 500px) / 4)); }
|
||||
}
|
||||
|
||||
/* Mobile: natural heights, let page scroll */
|
||||
@media (max-width: 480px) {
|
||||
#vv-monitor { grid-auto-rows: auto !important; }
|
||||
#vv-monitor .vv-card { overflow: visible !important; }
|
||||
#vv-monitor .vv-card > div { overflow-y: visible; min-height: auto; }
|
||||
}
|
||||
|
||||
/* Monitor responsive — 4-column grid at medium width */
|
||||
@media (max-width: 1400px) {
|
||||
/* CPU core bars — reduce gap/min-width at intermediate widths before cores get clipped */
|
||||
.vv-cpu-cores { gap: 2px !important; }
|
||||
.vv-cpu-core { min-width: 7px !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
#vv-monitor { grid-template-columns: repeat(4, 1fr) !important; }
|
||||
#vv-docker { grid-column: span 4 !important; }
|
||||
/* Reset explicit placements so cards reflow in the 4-col grid */
|
||||
#vv-docker-folders { grid-column: span 4 !important; }
|
||||
#vv-parity-card { grid-column: auto !important; }
|
||||
#vv-storage-card { grid-column: auto !important; }
|
||||
#vv-array-card { grid-column: auto !important; }
|
||||
/* Streams header: hide right chip group entirely, keep server badges + media type */
|
||||
.vv-stream-right { display: none; }
|
||||
}
|
||||
|
||||
/* Containers+VMs: single column when viewport is narrow */
|
||||
@media (max-width: 900px) {
|
||||
.vv-df-cols { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
|
||||
/* Phone layout — scheduler row/actions fixes + monitor single-column */
|
||||
@media (max-width: 480px) {
|
||||
/* Phone portrait — hint gone, cron stays inline but compact */
|
||||
.vv-cron-hint { display: none !important; }
|
||||
.vv-job-row .vv-cron { flex: 0 0 68px; width: 68px; font-size: 10px; margin-left: 30px; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* Allow action buttons to wrap rather than overflow the card */
|
||||
.vv-job-actions { flex-wrap: wrap; padding-left: 0; }
|
||||
/* Kill the inline margin-left:auto that pushes Advanced off-screen */
|
||||
.vv-advanced-toggle { margin-left: 0 !important; width: auto !important; }
|
||||
/* Keep cron from growing — label owns remaining row space */
|
||||
.vv-cron { flex: 0 0 75px; width: 75px; }
|
||||
/* Slightly tighter label on narrow screens */
|
||||
.vv-job-label { font-size: 14px; }
|
||||
/* Footer buttons wrap instead of overflowing */
|
||||
.vv-sched-footer { flex-wrap: wrap; }
|
||||
/* Log toolbar search — narrow on small screens */
|
||||
#vv-log-search { width: 80px; }
|
||||
/* Snapshot footer — smaller on mobile */
|
||||
.vv-snap-footer { gap: 10px !important; }
|
||||
.vv-snap-item { gap: 4px; }
|
||||
.vv-snap-label { font-size: 10px; }
|
||||
.vv-snap-bar { width: 44px; height: 5px; }
|
||||
.vv-snap-val { font-size: 11px; min-width: 26px; }
|
||||
.vv-snap-div { font-size: 11px; }
|
||||
.vv-snap-state { font-size: 11px; }
|
||||
#vv-snap-partner { font-size: 11px; }
|
||||
.vv-snap-media { font-size: 11px; }
|
||||
|
||||
/* CPU core bars — shrink gap and min-width so many cores don't overflow */
|
||||
.vv-cpu-cores { gap: 2px !important; }
|
||||
.vv-cpu-core { min-width: 6px !important; }
|
||||
|
||||
/* Monitor single-column — explicit placement cards need override too */
|
||||
#vv-monitor { grid-template-columns: 1fr !important; }
|
||||
#vv-monitor > .vv-card { grid-column: 1 / -1 !important; }
|
||||
#vv-docker-folders { grid-column: 1 / -1 !important; }
|
||||
}
|
||||
|
||||
/* Shared footer (Save Schedule left, info right) — same min-height so log card ends level with script cards */
|
||||
.vv-sched-footer { display: flex; align-items: center; gap: 10px;
|
||||
margin-top: 12px; padding: 10px 0; border-top: 1px solid #333;
|
||||
flex-shrink: 0; min-height: 72px; box-sizing: border-box; }
|
||||
.vv-sched-info { color: #666; font-size: 14px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.vv-save-btn { padding: 5px 18px; background: #4caf50; border: none; color: #fff;
|
||||
border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
.vv-save-btn:hover { background: #388e3c; }
|
||||
.vv-save-status { font-size: 12px; color: #aaa; }
|
||||
.vv-run-btn { border-color: #2196f3 !important; color: #2196f3 !important; }
|
||||
.vv-run-btn:hover { background: #0d47a1 !important; color: #fff !important;
|
||||
border-color: #2196f3 !important; }
|
||||
.vv-dry-btn { border-color: #ff9800 !important; color: #ff9800 !important; }
|
||||
.vv-dry-btn:hover { background: #e65100 !important; color: #fff !important;
|
||||
border-color: #ff9800 !important; }
|
||||
.vv-log-btn { border-color: #555 !important; color: #666 !important; }
|
||||
.vv-log-btn:hover { background: #333 !important; color: #aaa !important; border-color: #777 !important; }
|
||||
.vv-log-btn.vv-has-log { border-color: #4caf50 !important; color: #4caf50 !important; }
|
||||
.vv-log-btn.vv-has-log:hover { background: #1b5e20 !important; color: #fff !important;
|
||||
border-color: #4caf50 !important; }
|
||||
.vv-edit-btn { border-color: #7b1fa2 !important; color: #ce93d8 !important; }
|
||||
.vv-edit-btn:hover { background: #4a148c !important; color: #fff !important; border-color: #7b1fa2 !important; }
|
||||
.vv-add-script-btn { background: #1565c0 !important; border: none !important; color: #fff !important; margin-left: 8px; }
|
||||
.vv-add-script-btn:hover { background: #0d47a1 !important; }
|
||||
.vv-save-script-btn-style { background: #1565c0 !important; border: none !important; color: #fff !important; }
|
||||
.vv-save-script-btn-style:hover { background: #0d47a1 !important; }
|
||||
.vv-delete-btn { background: #b71c1c !important; border: none !important; color: #fff !important; margin-left: 4px; }
|
||||
.vv-delete-btn:hover { background: #7f0000 !important; }
|
||||
.vv-conf-btn { border-color: #00838f !important; color: #4dd0e1 !important; }
|
||||
.vv-conf-btn:hover { background: #006064 !important; color: #fff !important; border-color: #00838f !important; }
|
||||
|
||||
/* Config form */
|
||||
#vv-confform { padding: 2px 4px; }
|
||||
.vv-cf-group { border-bottom: 1px solid #252525; padding-bottom: 14px; margin-bottom: 14px; }
|
||||
.vv-cf-group:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
.vv-cf-group-header { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: bold;
|
||||
color: #7a9eb5; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
|
||||
.vv-cf-file { font-size: 10px; color: #555; background: #1a1a1a; border: 1px solid #2e2e2e;
|
||||
padding: 1px 6px; border-radius: 3px; font-weight: normal; text-transform: none;
|
||||
letter-spacing: 0; }
|
||||
.vv-cf-field { margin-bottom: 10px; }
|
||||
.vv-cf-key { font-family: monospace; font-size: 12px; color: #ccc; margin-bottom: 3px; }
|
||||
.vv-cf-desc { font-size: 11px; color: #666; margin-bottom: 4px; font-style: italic; line-height: 1.4; }
|
||||
.vv-cf-scalar { display: block; width: 100%; box-sizing: border-box; background: #111; border: 1px solid #333;
|
||||
color: #ddd; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; }
|
||||
.vv-cf-scalar:focus { border-color: #555; outline: none; }
|
||||
.vv-cf-array { display: block; width: 100%; box-sizing: border-box; background: #0d0d0d; border: 1px solid #333;
|
||||
color: #ccc; padding: 8px; border-radius: 4px; font-family: monospace; font-size: 11px;
|
||||
line-height: 1.6; resize: vertical; min-height: 60px; }
|
||||
.vv-cf-array:focus { border-color: #555; outline: none; }
|
||||
.vv-cf-empty { color: #555; font-size: 13px; font-style: italic; padding: 20px 4px; text-align: center; margin: 0; }
|
||||
.vv-custom-empty { color: #666; font-size: 13px; padding: 8px 4px; margin: 0; font-style: italic; }
|
||||
.vv-custom-count { font-size: 12px; color: #666; margin-left: 8px; flex-shrink: 0; }
|
||||
.vv-section-sep { font-size: 11px; font-weight: bold; color: #666; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; padding: 10px 4px 4px; border-top: 1px solid #222; margin-top: 8px; }
|
||||
#vv-editor { flex-direction: column; gap: 0; }
|
||||
.vv-editor-body { font-family: monospace; font-size: 12px; background: #0d0d0d; color: #ccc;
|
||||
border: 1px solid #333; border-radius: 4px; padding: 10px 12px; resize: none;
|
||||
line-height: 1.5; scroll-behavior: auto; tab-size: 2; width: 100%; box-sizing: border-box;
|
||||
white-space: pre; overflow-x: auto; }
|
||||
|
||||
/* Editor layout: gutter + inner area — unified bordered block */
|
||||
#vv-editor-wrap { display: flex; border: 1px solid #2e2e2e; border-radius: 4px 4px 0 0;
|
||||
overflow: hidden; background: #0d0d0d; }
|
||||
#vv-ln-gutter { width: 46px; min-width: 46px; flex-shrink: 0;
|
||||
background: #0a0a0a; border-right: 1px solid #1c1c1c;
|
||||
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
|
||||
color: #3a3a3a; text-align: right; padding: 10px 8px 10px 0;
|
||||
overflow: hidden; user-select: none; white-space: pre; }
|
||||
.vv-gln-cur { color: #ccc; font-weight: bold; }
|
||||
#vv-editor-inner { position: relative; flex: 1; overflow: hidden; background: #0d0d0d; }
|
||||
|
||||
/* Current line highlight — sits behind overlay */
|
||||
#vv-cur-line { position: absolute; left: 0; right: 0; height: 18px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-top: 1px solid rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
pointer-events: none; display: none; }
|
||||
|
||||
/* Syntax-highlight overlay — transparent bg so cur-line shows through */
|
||||
#vv-hl-overlay { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
margin: 0; padding: 10px 12px; box-sizing: border-box;
|
||||
font-family: monospace; font-size: 12px; line-height: 1.5; tab-size: 2;
|
||||
white-space: pre; overflow: hidden;
|
||||
pointer-events: none; user-select: none;
|
||||
background: transparent; border: none; border-radius: 0; }
|
||||
.vv-editor-hl #vv-hl-overlay { display: block; }
|
||||
.vv-editor-hl #vv-editor-body { color: transparent; caret-color: #ddd; background: transparent;
|
||||
border: none !important; border-radius: 0 !important; }
|
||||
|
||||
/* Visible selection — works even when text is transparent */
|
||||
.vv-editor-hl #vv-editor-body::selection { background: rgba(100,149,237,0.35); color: transparent; }
|
||||
.vv-editor-hl #vv-editor-body::-moz-selection{ background: rgba(100,149,237,0.35); color: transparent; }
|
||||
|
||||
/* Word match and search marks in the overlay */
|
||||
.vv-word-mark { background: rgba(255,220,60,0.14); outline: 1px solid rgba(255,220,60,0.35); border-radius: 2px; }
|
||||
.vv-search-mark { background: rgba(255,140,20,0.22); outline: 1px solid rgba(255,140,20,0.45); border-radius: 2px; }
|
||||
.vv-search-mark-current{ background: rgba(255,90,0,0.40); outline: 2px solid rgba(255,100,0,0.7); border-radius: 2px; }
|
||||
|
||||
/* Slim scrollbar for editor */
|
||||
#vv-editor-body::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
#vv-editor-body::-webkit-scrollbar-track { background: #0a0a0a; }
|
||||
#vv-editor-body::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 4px; }
|
||||
#vv-editor-body::-webkit-scrollbar-thumb:hover { background: #444; }
|
||||
|
||||
/* Editor find bar — two-row (find + optional replace) */
|
||||
#vv-editor-find { display: none; flex-direction: column; gap: 0; padding: 5px 8px;
|
||||
margin-bottom: 5px; background: #161616; border: 1px solid #333;
|
||||
border-radius: 4px; font-size: 12px; }
|
||||
#vv-editor-find.vv-find-open { display: flex; }
|
||||
.vv-find-row { display: flex; align-items: center; gap: 6px; }
|
||||
.vv-replace-row { display: none; align-items: center; gap: 6px;
|
||||
margin-top: 4px; padding-top: 4px; border-top: 1px solid #222; }
|
||||
.vv-replace-row.vv-repl-open { display: flex; }
|
||||
.vv-find-expand { font-size: 10px; color: #555; cursor: pointer; flex-shrink: 0;
|
||||
width: 14px; text-align: center; user-select: none;
|
||||
transition: transform 0.15s; line-height: 1; }
|
||||
.vv-find-expand:hover { color: #aaa; }
|
||||
.vv-find-expand.open { transform: rotate(90deg); color: #888; }
|
||||
#vv-find-input, #vv-replace-input {
|
||||
flex: 1; min-width: 0; background: #0d0d0d; border: 1px solid #333;
|
||||
color: #ddd; padding: 4px 8px; border-radius: 3px;
|
||||
font-family: monospace; font-size: 12px; }
|
||||
#vv-find-input:focus, #vv-replace-input:focus {
|
||||
border-color: #6495ed; outline: none; box-shadow: 0 0 0 1px rgba(100,149,237,0.25); }
|
||||
#vv-find-input.vv-find-no-match { border-color: #c62828; box-shadow: 0 0 0 1px rgba(198,40,40,0.3); }
|
||||
.vv-find-count { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; min-width: 64px; text-align: right; }
|
||||
.vv-find-nav-btn{ padding: 2px 8px !important; }
|
||||
.vv-repl-btn { border-color: #444 !important; color: #777 !important; }
|
||||
.vv-repl-btn:hover { border-color: #777 !important; color: #ddd !important; }
|
||||
.vv-find-x { color: #555; cursor: pointer; font-size: 16px; line-height: 1;
|
||||
padding: 0 2px; flex-shrink: 0; }
|
||||
.vv-find-x:hover { color: #aaa; }
|
||||
|
||||
/* Editor status bar */
|
||||
#vv-editor-status { display: none; align-items: center; gap: 10px; padding: 3px 10px;
|
||||
background: #0e0e0e; border: 1px solid #2e2e2e; border-top: none;
|
||||
border-radius: 0 0 4px 4px; font-family: monospace;
|
||||
font-size: 10px; color: #444; user-select: none; flex-shrink: 0; }
|
||||
#vv-editor-status.vv-ed-active { display: flex; }
|
||||
.vv-es-pos { color: #666; }
|
||||
.vv-es-sel { color: #555; }
|
||||
.vv-es-lang { margin-left: auto; color: #4a6a7a; }
|
||||
/* Status bar action buttons */
|
||||
.vv-es-btn { background: none; border: none; cursor: pointer; color: #555;
|
||||
font-size: 10px; padding: 1px 4px; border-radius: 2px; font-family: monospace; }
|
||||
.vv-es-btn:hover { color: #aaa; background: rgba(255,255,255,0.05); }
|
||||
.vv-es-btn.active { color: #6495ed; }
|
||||
#vv-es-fontsize { color: #444; font-size: 10px; min-width: 26px; text-align: center; }
|
||||
|
||||
/* Word wrap mode — applied to #vv-editor-wrap when wrap is enabled */
|
||||
#vv-editor-wrap.vv-ed-wrap .vv-editor-body { white-space: pre-wrap !important; overflow-x: hidden !important; }
|
||||
#vv-editor-wrap.vv-ed-wrap #vv-hl-overlay { white-space: pre-wrap !important; word-break: break-word !important; }
|
||||
#vv-editor-wrap.vv-ed-wrap #vv-ln-gutter { display: none; }
|
||||
#vv-editor-wrap.vv-ed-wrap #vv-cur-line { display: none !important; }
|
||||
|
||||
/* Indent guides — 1px lines every 2 chars, starting at indent level 1 */
|
||||
#vv-editor-inner {
|
||||
position: relative; flex: 1; overflow: hidden;
|
||||
background-color: #0d0d0d;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.045) 0 1px,
|
||||
transparent 1px 2ch
|
||||
);
|
||||
background-position: calc(12px + 2ch) 0;
|
||||
}
|
||||
|
||||
/* Go to line bar */
|
||||
#vv-goto-bar { display: none; align-items: center; gap: 8px; padding: 5px 8px;
|
||||
margin-bottom: 5px; background: #161616; border: 1px solid #333;
|
||||
border-radius: 4px; font-size: 12px; }
|
||||
#vv-goto-bar.vv-goto-open { display: flex; }
|
||||
#vv-goto-input { width: 68px; background: #0d0d0d; border: 1px solid #333; color: #ddd;
|
||||
padding: 4px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; }
|
||||
#vv-goto-input:focus { border-color: #6495ed; outline: none; }
|
||||
.vv-goto-info { font-size: 11px; color: #555; white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* Undo / Redo buttons */
|
||||
.vv-undo-redo-btn { border-color: #444 !important; color: #666 !important; }
|
||||
.vv-undo-redo-btn:not([disabled]):hover { border-color: #777 !important; color: #ccc !important; background: #1e1e1e !important; }
|
||||
.vv-undo-redo-btn[disabled] { opacity: 0.28 !important; cursor: default !important; pointer-events: none; }
|
||||
.vv-undo-count { font-size: 10px; opacity: 0.7; margin-left: 2px; }
|
||||
|
||||
/* Suggestions panel — accordion */
|
||||
#vv-suggestions { overflow-y: auto; }
|
||||
.vv-sug-block { border-bottom: 1px solid #1e1e1e; }
|
||||
.vv-sug-header { display: flex; align-items: center; gap: 8px; padding: 7px 4px;
|
||||
cursor: pointer; user-select: none; flex-wrap: wrap; }
|
||||
.vv-sug-header:hover { background: rgba(255,255,255,0.03); }
|
||||
.vv-sug-chevron { color: #555; font-size: 11px; flex-shrink: 0; width: 10px; }
|
||||
.vv-sug-title { color: #7a9eb5; font-weight: bold; font-size: 13px; flex: 1; min-width: 120px; }
|
||||
.vv-sug-cron { color: #ddd; background: #111; border: 1px solid #333; padding: 1px 6px;
|
||||
border-radius: 3px; font-size: 11px; font-family: monospace; white-space: nowrap; flex-shrink: 0; }
|
||||
.vv-sug-label { color: #666; font-size: 12px; flex: 1; min-width: 0; white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-sug-status { font-size: 11px; flex-shrink: 0; margin-left: auto; }
|
||||
.vv-sug-on { color: #4caf50; }
|
||||
.vv-sug-off { color: #777; }
|
||||
.vv-sug-none { color: #444; }
|
||||
.vv-sug-body { padding: 0 14px 10px; }
|
||||
.vv-sug-desc { font-size: 12px; color: #888; white-space: pre-wrap; word-break: break-word;
|
||||
background: none; border: none; margin: 4px 0 8px; padding: 0;
|
||||
font-family: inherit; line-height: 1.6; }
|
||||
.vv-sug-scripts { display: flex; flex-direction: column; gap: 4px; }
|
||||
.vv-sug-script-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.vv-sug-inline-cron { color: #aaa; background: #111; padding: 1px 5px; border-radius: 3px;
|
||||
font-size: 11px; white-space: nowrap; }
|
||||
.vv-sug-path { color: #888; font-family: monospace; }
|
||||
.vv-sug-configured { color: #4caf50; font-size: 11px; }
|
||||
/* Info sections inside Scheduler Information panel */
|
||||
.vv-info-block .vv-sug-title { color: #9ab; }
|
||||
.vv-info-body { padding: 2px 10px 10px; }
|
||||
.vv-info-cols { margin: 0; padding-left: 16px; columns: 2; column-gap: 20px; column-fill: balance; }
|
||||
.vv-info-cols li { font-size: 12px; color: #aaa; margin-bottom: 5px; break-inside: avoid; line-height: 1.5; }
|
||||
.vv-info-cols .vv-info-sep { column-span: all; list-style: none; margin: 10px -4px 6px;
|
||||
padding: 4px 8px; background: #161a1d; border-left: 2px solid #1e6fa5;
|
||||
font-size: 10px; font-weight: bold; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: #4a8ab5; break-inside: avoid; }
|
||||
.vv-info-cols li strong { color: #ccc; }
|
||||
.vv-info-cols code { background: #1a1a1a; padding: 0 4px; border-radius: 2px;
|
||||
font-size: 11px; color: #9ab; border: 1px solid #333; }
|
||||
.vv-info-divider { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: #444;
|
||||
padding: 10px 12px 4px; border-top: 1px solid #2a2a2a; margin-top: 2px; }
|
||||
|
||||
/* How do I use this — pinned at top of suggestions panel */
|
||||
#vv-how-to-use {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
background: #1e1e1e;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
/* Run-status dot colours (tree + activity list) */
|
||||
.vv-stat-ok { color: #4caf50; }
|
||||
.vv-stat-warn { color: #ff9800; }
|
||||
.vv-stat-error{ color: #f44336; }
|
||||
.vv-stat-skip { color: #607d8b; }
|
||||
.vv-stat-none { color: #444; }
|
||||
|
||||
/* Cron humanizer hint — left of cron input; first to collapse under space pressure */
|
||||
.vv-cron-hint { font-size: 10px; color: #505050; white-space: nowrap;
|
||||
flex: 0 10 auto; min-width: 0; max-width: 130px;
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
pointer-events: none; user-select: none; }
|
||||
|
||||
/* Recent Activity list */
|
||||
.vv-activity-list { display: flex; flex-direction: column; }
|
||||
.vv-activity-row { display: flex; align-items: center; gap: 6px; padding: 4px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 11px; cursor: pointer; }
|
||||
.vv-activity-row:last-child { border-bottom: none; }
|
||||
.vv-activity-row:hover .vv-activity-label { color: #fff; }
|
||||
.vv-activity-dot { flex-shrink: 0; font-size: 9px; }
|
||||
.vv-activity-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; color: #ccc; }
|
||||
.vv-activity-ago { color: #555; font-size: 10px; white-space: nowrap; flex-shrink: 0; }
|
||||
.vv-activity-dur { color: #444; font-size: 10px; white-space: nowrap; flex-shrink: 0;
|
||||
min-width: 32px; text-align: right; }
|
||||
|
||||
/* Log search */
|
||||
#vv-log-search { width: 120px; font-size: 11px; padding: 2px 6px;
|
||||
background: #111; border: 1px solid #333; color: #ccc;
|
||||
border-radius: 3px; font-family: monospace; }
|
||||
#vv-log-search:focus { border-color: #555; outline: none; }
|
||||
mark { background: #5d4037; color: #ffcc80; border-radius: 2px; }
|
||||
.vv-log-dim { opacity: 0.2; }
|
||||
|
||||
/* Cron Calculator */
|
||||
.vv-calc-wrap { display: flex; flex-direction: column; gap: 8px; }
|
||||
.vv-calc-in { width: 100%; background: #111; border: 1px solid #333; color: #ccc;
|
||||
padding: 5px 8px; border-radius: 3px; font-family: monospace; font-size: 12px;
|
||||
box-sizing: border-box; }
|
||||
.vv-calc-in:focus { border-color: #555; outline: none; }
|
||||
.vv-calc-expr { font-family: monospace; font-size: 13px; color: #7cb8e8;
|
||||
background: #0d1117; padding: 4px 8px; border-radius: 3px; }
|
||||
.vv-calc-desc { font-size: 12px; color: #aaa; padding: 2px 2px 0; }
|
||||
.vv-calc-hint { font-size: 11px; color: #555; font-style: italic; }
|
||||
.vv-calc-runs-lbl { font-size: 10px; color: #555; text-transform: uppercase;
|
||||
letter-spacing: .05em; margin-top: 4px; }
|
||||
.vv-calc-runs { display: flex; flex-direction: column; gap: 2px; }
|
||||
.vv-calc-run-row { display: flex; gap: 8px; font-size: 11px; }
|
||||
.vv-calc-run-in { color: #4caf50; white-space: nowrap; min-width: 56px; }
|
||||
.vv-calc-run-at { color: #666; }
|
||||
.vv-calc-apply-btn { align-self: flex-start; margin-top: 2px; }
|
||||
|
||||
/* Board blocks — Next Runs, Errors, Locks, Partner, Disabled */
|
||||
.vv-board-placeholder { color: #555; font-size: 11px; padding: 3px 0; }
|
||||
|
||||
.vv-hdr-badge { display: inline-block; padding: 1px 7px; border-radius: 10px;
|
||||
font-size: 10px; font-weight: bold; flex-shrink: 0; }
|
||||
.vv-hdr-badge-red { background: #7f0000; color: #ef9a9a; }
|
||||
.vv-hdr-badge-orange { background: #5d2000; color: #ffcc80; }
|
||||
.vv-hdr-badge-gray { background: #2a2a2a; color: #aaa; }
|
||||
|
||||
/* Next Runs */
|
||||
.vv-nextrun-list { display: flex; flex-direction: column; }
|
||||
.vv-nextrun-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-nextrun-row:last-child { border-bottom: none; }
|
||||
.vv-nr-label { color: #ccc; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vv-nr-cron { color: #555; font-family: monospace; font-size: 10px; flex-shrink: 0; }
|
||||
.vv-nr-in { color: #4caf50; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
|
||||
.vv-nr-at { color: #666; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* Errors */
|
||||
.vv-errors-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.vv-err-row { padding: 5px 0; border-bottom: 1px solid #1a1a1a; }
|
||||
.vv-err-row:last-child { border-bottom: none; }
|
||||
.vv-err-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; }
|
||||
.vv-err-script { color: #e07070; font-size: 11px; font-weight: bold; }
|
||||
.vv-err-age { color: #555; font-size: 10px; flex: 1; padding-left: 6px; white-space: nowrap; }
|
||||
.vv-ack-btn { margin-left: auto; font-size: 10px; padding: 1px 6px;
|
||||
color: #555; border-color: #333; flex-shrink: 0; }
|
||||
.vv-ack-btn:hover { color: #aaa; border-color: #555; }
|
||||
.vv-err-line { color: #888; font-size: 11px; word-break: break-all; line-height: 1.4; }
|
||||
|
||||
/* Locks */
|
||||
.vv-locks-list { display: flex; flex-direction: column; }
|
||||
.vv-lock-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-lock-row:last-child { border-bottom: none; }
|
||||
.vv-lk-name { color: #e8a87c; flex: 1; }
|
||||
.vv-lk-age { color: #777; font-size: 11px; white-space: nowrap; }
|
||||
.vv-lock-clear { font-size: 11px !important; padding: 1px 7px !important;
|
||||
border-color: #b71c1c !important; color: #ef9a9a !important; }
|
||||
.vv-lock-clear:hover { background: #7f0000 !important; color: #fff !important; }
|
||||
|
||||
/* Partner */
|
||||
.vv-partner-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
|
||||
.vv-partner-name { color: #ccc; }
|
||||
.vv-partner-detail { color: #666; font-size: 11px; }
|
||||
.vv-partner-down { color: #f44336 !important; }
|
||||
|
||||
/* Disabled scripts */
|
||||
.vv-disabled-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.vv-disabled-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-disabled-row:last-child { border-bottom: none; }
|
||||
.vv-disabled-name { color: #888; flex: 1; }
|
||||
.vv-disabled-grp { color: #444; font-size: 10px; font-family: monospace; white-space: nowrap; }
|
||||
|
||||
/* Notification board */
|
||||
.vv-nb-board { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 7px 10px; background: #141414; border-bottom: 1px solid #222;
|
||||
font-size: 12px; min-height: 34px; }
|
||||
.vv-nb-stat { color: #aaa; }
|
||||
.vv-nb-sep { color: #444; }
|
||||
.vv-nb-running { color: #f0a040; }
|
||||
.vv-nb-conf-btns { display: flex; gap: 6px; margin-left: auto; }
|
||||
.vv-nb-conf-btn { font-size: 11px; font-family: monospace; }
|
||||
|
||||
/* Advanced mode button */
|
||||
.vv-adv-mode-btn { border: 1px solid #555; color: #999; transition: background 0.15s, color 0.15s, border-color 0.15s; }
|
||||
.vv-adv-mode-btn:hover { border-color: #888; color: #fff; }
|
||||
.vv-adv-mode-btn.vv-adv-mode-on { background: #1565c0; border-color: #1565c0; color: #fff; }
|
||||
|
||||
/* Script browser in suggestions panel */
|
||||
.vv-sb-desc { font-size: 12px; color: #888; margin: 4px 0 6px; line-height: 1.5; }
|
||||
.vv-sb-child-desc { margin-left: 16px; }
|
||||
.vv-sb-hdr { font-family: monospace; font-size: 11px; color: #666; white-space: pre-wrap;
|
||||
word-break: break-word; background: none; border: none; margin: 4px 0 8px;
|
||||
padding: 0; line-height: 1.5; border-left: 2px solid #222; padding-left: 8px; }
|
||||
.vv-sb-full { font-family: monospace; font-size: 11px; color: #888; white-space: pre-wrap;
|
||||
word-break: break-word; background: #0a0a0a; border: 1px solid #222;
|
||||
border-radius: 3px; margin: 6px 0 8px; padding: 8px 10px; line-height: 1.5;
|
||||
max-height: 480px; overflow-y: auto; }
|
||||
.vv-sb-child-block { border-top: 1px solid #1a1a1a; margin-top: 8px; padding-top: 8px; }
|
||||
.vv-sb-child-name { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; flex-wrap: wrap; }
|
||||
|
||||
/* README content display */
|
||||
.vv-readme-body { font-family: monospace; font-size: 11px; color: #777; white-space: pre-wrap;
|
||||
word-break: break-word; background: none; border: none; margin: 0;
|
||||
padding: 0 4px; line-height: 1.6; }
|
||||
|
||||
/* Script browser tree rows */
|
||||
#vv-sb-tree { padding: 0; }
|
||||
.vv-sb-entry { }
|
||||
.vv-sb-row { display: flex; align-items: center; gap: 6px; padding: 5px 6px;
|
||||
cursor: pointer; border-radius: 3px; user-select: none;
|
||||
transition: background 0.1s, box-shadow 0.1s; }
|
||||
.vv-sb-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.vv-sb-selected { background: rgba(100,149,237,0.14) !important;
|
||||
outline: 1px solid rgba(100,149,237,0.4);
|
||||
box-shadow: inset 3px 0 0 #6495ed; }
|
||||
.vv-sb-orch-row { border-bottom: 1px solid #1c1c1c; }
|
||||
.vv-sb-child-row { padding-left: 2px; }
|
||||
.vv-sb-expand { width: 14px; flex-shrink: 0; color: #555; font-size: 10px; text-align: center; }
|
||||
.vv-sb-expand:hover { color: #aaa; }
|
||||
.vv-sb-leaf { cursor: default; pointer-events: none; }
|
||||
.vv-sb-name { flex: 1; font-size: 13px; color: #b0c4d0; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-sb-orch-row .vv-sb-name { font-weight: bold; color: #9ab; }
|
||||
.vv-sb-children { padding-left: 10px; }
|
||||
.vv-sb-child-indent { width: 14px; flex-shrink: 0; color: #333; font-size: 11px;
|
||||
text-align: center; pointer-events: none; }
|
||||
.vv-sb-badge { flex-shrink: 0; font-size: 11px; }
|
||||
.vv-sb-cron { color: #555; background: #111; border: 1px solid #222;
|
||||
padding: 0 4px; border-radius: 2px; font-size: 10px;
|
||||
font-family: monospace; flex-shrink: 0; }
|
||||
.vv-sb-status { font-size: 10px; flex-shrink: 0; }
|
||||
|
||||
/* Script info content area */
|
||||
.vv-si-hdr { font-family: monospace; font-size: 12px; color: #4d894d; white-space: pre-wrap;
|
||||
word-break: break-word; background: none; border: none; margin: 0;
|
||||
padding: 8px 12px; line-height: 1.6; }
|
||||
.vv-si-src { font-family: monospace; font-size: 11px; white-space: pre-wrap;
|
||||
word-break: break-word; background: none; border: none; margin: 0;
|
||||
padding: 8px 12px; line-height: 1.5; }
|
||||
|
||||
/* Cog settings button next to script name */
|
||||
.vv-cog-btn { cursor:pointer; color:#444; font-size:.85em; margin-right:4px;
|
||||
line-height:1; vertical-align:middle; user-select:none; }
|
||||
.vv-cog-btn:hover { color:#aaa; }
|
||||
|
||||
/* Enriched script info blocks (advanced mode: header + docs + config) */
|
||||
.vv-sinfo-block { border-top: 1px solid #222; padding: 0; }
|
||||
.vv-sinfo-block:first-child { border-top: none; }
|
||||
.vv-sinfo-lbl { font-size: 10px; font-weight: bold; text-transform: uppercase;
|
||||
letter-spacing: .06em; color: #555; padding: 6px 12px 2px; }
|
||||
|
||||
/* Syntax highlight tokens — dark theme (VSCode-inspired) */
|
||||
.vv-hl-sep { color: #2d2d2d; }
|
||||
.vv-hl-shebang { color: #4a4a4a; }
|
||||
.vv-hl-hash { color: #4a7340; }
|
||||
.vv-hl-comment { color: #6a9955; }
|
||||
.vv-hl-section { color: #9cdcfe; font-weight: bold; letter-spacing: 0.04em; }
|
||||
.vv-hl-key { color: #9cdcfe; }
|
||||
.vv-hl-value { color: #ce9178; }
|
||||
.vv-hl-cron { color: #d7ba7d; font-weight: bold; }
|
||||
.vv-hl-text { color: #4d894d; }
|
||||
.vv-hl-keyword { color: #569cd6; }
|
||||
.vv-hl-builtin { color: #4ec9b0; }
|
||||
.vv-hl-string { color: #ce9178; }
|
||||
.vv-hl-var { color: #d7ba7d; }
|
||||
.vv-hl-number { color: #b5cea8; }
|
||||
.vv-hl-op { color: #808080; }
|
||||
|
||||
/* Log panel */
|
||||
.vv-log-panel { margin-top: 10px; border-top: 1px solid #333; padding-top: 8px; }
|
||||
.vv-log-toolbar { display: flex; justify-content: space-between; align-items: center;
|
||||
flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
|
||||
.vv-log-ts { font-size: 11px; color: #666; }
|
||||
.vv-log-pre { background: #0d0d0d; border: 1px solid #333; border-radius: 4px;
|
||||
padding: 10px 12px; margin: 0; font-family: monospace; font-size: 12px;
|
||||
color: #ccc; white-space: pre-wrap; word-break: break-all;
|
||||
max-height: 340px; overflow-y: auto; line-height: 1.5;
|
||||
scroll-behavior: auto; }
|
||||
|
||||
/* Toggle switch */
|
||||
.vv-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
|
||||
.vv-toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.vv-slider { position: absolute; inset: 0; background: #444; border-radius: 20px; cursor: pointer;
|
||||
transition: 0.2s; }
|
||||
.vv-slider:before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; bottom: 3px;
|
||||
background: #fff; border-radius: 50%; transition: 0.2s; }
|
||||
.vv-toggle input:checked + .vv-slider { background: #4caf50; }
|
||||
.vv-toggle input:checked + .vv-slider:before { transform: translateX(16px); }
|
||||
|
||||
/* Config editor */
|
||||
#vv-conf-tabs { display: flex; gap: 4px; margin-bottom: 8px; }
|
||||
.vv-conf-tab { padding: 4px 12px; text-decoration: none; color: #aaa;
|
||||
border: 1px solid #444; border-radius: 4px; font-size: 13px; }
|
||||
.vv-conf-tab.active { color: #fff; background: #333; border-color: #666; }
|
||||
#vv-conf-editor { width: 100%; min-height: 500px; background: #111; color: #ddd;
|
||||
border: 1px solid #444; padding: 12px; font-family: monospace;
|
||||
font-size: 13px; line-height: 1.5; border-radius: 4px; box-sizing: border-box; resize: vertical; }
|
||||
#vv-conf-actions { margin-top: 8px; display: flex; align-items: center; gap: 10px; }
|
||||
#vv-conf-actions button { padding: 6px 18px; background: #4caf50; border: none;
|
||||
color: #fff; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
#vv-conf-actions button:hover { background: #388e3c; }
|
||||
#vv-conf-status { font-size: 13px; color: #aaa; }
|
||||
|
||||
/* Docs */
|
||||
#vv-docs { display: flex; gap: 16px; }
|
||||
#vv-docs-sidebar { width: 220px; flex-shrink: 0; }
|
||||
#vv-docs-sidebar h3 { font-size: 12px; text-transform: uppercase; color: #888; margin: 0 0 8px; }
|
||||
#vv-docs-sidebar ul { list-style: none; padding: 0; margin: 0; }
|
||||
#vv-docs-sidebar li { margin: 2px 0; }
|
||||
#vv-docs-sidebar a { display: block; padding: 3px 8px; font-size: 12px; color: #aaa;
|
||||
text-decoration: none; border-radius: 3px; }
|
||||
#vv-docs-sidebar a:hover { background: #222; color: #fff; }
|
||||
#vv-docs-sidebar a.active { background: #333; color: #fff; }
|
||||
#vv-docs-content { flex: 1; min-width: 0; }
|
||||
.vv-doc-body { background: #1a1a1a; border: 1px solid #444; border-radius: 6px;
|
||||
padding: 20px; line-height: 1.7; }
|
||||
.vv-doc-body h1, .vv-doc-body h2, .vv-doc-body h3 { color: #ddd; }
|
||||
.vv-doc-body code { background: #111; padding: 1px 5px; border-radius: 3px; font-size: 12px; }
|
||||
.vv-doc-body pre { background: #111; padding: 12px; border-radius: 4px; overflow-x: auto; }
|
||||
.vv-doc-hint { font-size: 12px; color: #666; margin-top: 8px; }
|
||||
|
||||
/* Media stream sessions */
|
||||
.vv-stream-servers { display: flex; justify-content: space-between; align-items: center;
|
||||
flex-wrap: nowrap; gap: 8px; margin-bottom: 10px; }
|
||||
/* Left side: server badges + media type */
|
||||
.vv-stream-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
/* Right side: device / resolution / codec chip groups */
|
||||
.vv-stream-right { display: flex; align-items: center; gap: 0; flex-wrap: nowrap; flex-shrink: 0; }
|
||||
.vv-server-badge { background: #333; border: 1px solid #555; color: #aaa;
|
||||
font-size: 10px; padding: 1px 7px; border-radius: 10px; }
|
||||
.vv-server-badge-sm { font-size: 9px; padding: 1px 6px; flex-shrink: 0; }
|
||||
/* Chip groups — server badge row */
|
||||
.vv-device-sep { color: #2a2a2a; font-size: 12px; margin: 0 2px; user-select: none; }
|
||||
/* shared chip geometry */
|
||||
.vv-device-chip,
|
||||
.vv-chip-device, .vv-chip-res, .vv-chip-codec, .vv-chip-mtype {
|
||||
font-size: 10px; padding: 1px 7px; border-radius: 10px;
|
||||
white-space: nowrap; border: 1px solid; }
|
||||
/* media type — purple */
|
||||
.vv-chip-mtype { color: #9a6abf; background: #1a0d2e; border-color: #3a1e55; }
|
||||
/* device — blue */
|
||||
.vv-chip-device { color: #5a9ec8; background: #0d1e2e; border-color: #1c3f5c; }
|
||||
/* resolution — teal */
|
||||
.vv-chip-res { color: #4aaa9a; background: #0b1e1c; border-color: #1a4540; }
|
||||
/* codec — amber */
|
||||
.vv-chip-codec { color: #b38820; background: #201500; border-color: #483300; }
|
||||
/* legacy fallback */
|
||||
.vv-device-chip { color: #666; background: #181818; border-color: #252525; }
|
||||
/* chip group wrappers — inline so they flow with the badge row */
|
||||
.vv-chip-group-device, .vv-chip-group-res, .vv-chip-group-codec { display: contents; }
|
||||
.vv-stream-empty { color: #555; font-style: italic; font-size: 12px; margin: 4px 0; }
|
||||
.vv-stream-empty span { font-size: 11px; color: #444; }
|
||||
.vv-stream-row { margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #282828; }
|
||||
.vv-stream-row:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
|
||||
.vv-stream-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.vv-stream-icon { font-size: 10px; color: #888; flex-shrink: 0; }
|
||||
.vv-stream-title { flex: 1; font-size: 12px; color: #ddd; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-stream-meta { display: flex; gap: 10px; flex-wrap: wrap; font-size: 10px;
|
||||
color: #666; margin-bottom: 5px; }
|
||||
.vv-stream-user { color: #888; }
|
||||
.vv-stream-client { color: #555; }
|
||||
.vv-stream-method { font-weight: 500; }
|
||||
.vv-stream-time { color: #555; margin-left: auto; }
|
||||
.vv-stream-bar { height: 3px; background: #1a1a1a; border-radius: 2px; overflow: hidden; }
|
||||
.vv-stream-bar div { height: 100%; border-radius: 2px; transition: width 0.4s; }
|
||||
|
||||
/* Live var substitution colours */
|
||||
code.vv-live-var { color: #4caf50; background: #0d1f0d; }
|
||||
code.vv-unknown-var { color: #ff9800; background: #1f130d; }
|
||||
|
||||
/* ── Arrange mode ─────────────────────────────────────────────────────────── */
|
||||
.vv-drag-handle { cursor: grab; color: #555; font-size: 14px; padding: 0 5px 0 0; user-select: none; flex-shrink: 0; }
|
||||
.vv-drag-handle:hover { color: #888; }
|
||||
.vv-drag-handle:active { cursor: grabbing; color: #aaa; }
|
||||
/* Dragged item: semi-transparent with a dashed ring so you still see what you grabbed */
|
||||
.vv-drag-ghost { opacity: 0.4; outline: 1px dashed #555; border-radius: 4px; }
|
||||
/* Drop insertion line: blue glow to match selection accent */
|
||||
.vv-drop-line { height: 2px; background: #6495ed; border-radius: 2px; margin: 2px 0;
|
||||
pointer-events: none; box-shadow: 0 0 6px rgba(100,149,237,0.7); }
|
||||
.vv-arrange-active .vv-children { min-height: 28px; border: 1px dashed #2a2a2a; border-radius: 4px;
|
||||
padding: 4px 2px; transition: border-color 0.12s, background 0.12s; }
|
||||
/* Drop targets use blue to match selection theme */
|
||||
.vv-arrange-active .vv-children.vv-drop-target { border-color: #6495ed; background: rgba(100,149,237,0.07); }
|
||||
.vv-arrange-btn-active { background: #1a3a1e !important; color: #4caf50 !important; border-color: #2d5c33 !important; }
|
||||
/* Grabbing cursor while dragging anything */
|
||||
.vv-is-dragging, .vv-is-dragging * { cursor: grabbing !important; }
|
||||
.vv-arrange-save-btn { background: #1a3a1e; border-color: #2d5c33; color: #4caf50; }
|
||||
.vv-arrange-save-btn:hover { background: #22502a; }
|
||||
#vv-arrange-btn { background: #7b1fa2; border-color: #7b1fa2; }
|
||||
#vv-arrange-btn:hover { background: #4a148c; border-color: #4a148c; }
|
||||
|
||||
/* ── Arrange workspace panel ─────────────────────────────────────────────── */
|
||||
.vv-arrange-ws-hdr { font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase;
|
||||
letter-spacing: 0.6px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
|
||||
#vv-pending-badge { background: #ff9800; color: #000; font-size: 10px; padding: 1px 7px;
|
||||
border-radius: 10px; font-weight: bold; }
|
||||
.vv-arrange-pending-hdr { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
margin-bottom: 5px; }
|
||||
.vv-pending-row { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
||||
font-size: 11px; border-bottom: 1px solid #1e1e1e; }
|
||||
.vv-pending-script { color: #ddd; font-weight: 500; }
|
||||
.vv-pending-arrow { color: #555; font-size: 10px; }
|
||||
.vv-library-zone { border: 1px dashed #2a2a2a; border-radius: 4px; padding: 6px;
|
||||
min-height: 60px; transition: border-color 0.12s, background 0.12s; }
|
||||
.vv-library-zone.vv-drop-target { border-color: #ef5350; background: rgba(239,83,80,0.09);
|
||||
box-shadow: inset 0 0 8px rgba(239,83,80,0.08); }
|
||||
.vv-arrange-drop-hint { font-size: 10px; color: #444; text-align: center; padding: 2px 0 7px; }
|
||||
.vv-lib-card { background: #1c1c1c; border: 1px solid #2e2e2e; border-radius: 3px;
|
||||
padding: 4px 8px; margin-bottom: 4px; cursor: grab; display: flex;
|
||||
align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.vv-lib-card:hover { border-color: #444; }
|
||||
.vv-lib-card.vv-drag-ghost { opacity: 0.35; }
|
||||
.vv-lib-card-name { font-size: 16px; color: #ccc; font-weight: bold; }
|
||||
.vv-lib-card-path { font-size: 10px; color: #444; font-family: monospace; }
|
||||
|
||||
/* ── Custom script folders ────────────────────────────────────────────────── */
|
||||
.vv-folder-group { margin-bottom: 1px; }
|
||||
.vv-folder-row { display: flex; align-items: center; gap: 6px; padding: 3px 6px;
|
||||
cursor: pointer; border-radius: 3px; color: #888; font-size: 12px; user-select: none; }
|
||||
.vv-folder-row:hover { background: #1e1e1e; }
|
||||
.vv-folder-chevron { font-size: 10px; color: #555; width: 10px; flex-shrink: 0; }
|
||||
.vv-folder-name { flex: 1; font-weight: 500; color: #aaa; }
|
||||
.vv-folder-count { font-size: 10px; color: #555; background: #1c1c1c;
|
||||
padding: 0 5px; border-radius: 8px; border: 1px solid #2a2a2a; }
|
||||
.vv-folder-children { padding-left: 12px; min-height: 4px; }
|
||||
.vv-folder-children.vv-drop-target { background: rgba(100,149,237,0.07); border-radius: 4px;
|
||||
outline: 1px dashed rgba(100,149,237,0.4); }
|
||||
.vv-folder-new-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; }
|
||||
.vv-new-folder-btn { background: #0277bd !important; border: none !important; color: #fff !important; }
|
||||
.vv-new-folder-btn:hover { background: #01579b !important; }
|
||||
|
||||
/* ── Snapshot footer (right panel status bar) ────────────────────────────── */
|
||||
.vv-snap-footer { display: flex !important; flex-direction: row !important;
|
||||
align-items: center !important; justify-content: center; gap: 20px; flex-wrap: wrap; }
|
||||
.vv-snap-item { display: flex; align-items: center; gap: 7px; }
|
||||
.vv-snap-label { font-size: 12px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.vv-snap-bar { width: 90px; height: 8px; background: #222; border-radius: 4px; overflow: hidden; flex-shrink: 0; }
|
||||
.vv-snap-bar span { display: block; height: 100%; border-radius: 4px; width: 0;
|
||||
transition: width 0.5s, background-color 0.5s; }
|
||||
.vv-snap-val { font-size: 15px; color: #aaa; font-family: monospace; min-width: 36px; }
|
||||
.vv-snap-div { color: #333; font-size: 15px; }
|
||||
.vv-snap-state { font-size: 15px; font-weight: bold; }
|
||||
#vv-snap-partner { font-size: 15px; color: #666; }
|
||||
.vv-snap-media { font-size: 15px; color: #666; }
|
||||
|
||||
/* ── Containers and VMs card ──────────────────────────────────────────────── */
|
||||
.vv-df-section-hdr { font-size: 10px; font-weight: bold; color: #555; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; padding: 4px 2px 5px; border-bottom: 1px solid #222;
|
||||
margin-bottom: 4px; }
|
||||
.vv-df-empty { font-size: 12px; color: #555; font-style: italic; padding: 4px 2px 8px; }
|
||||
.vv-df-vm-row { display: flex; align-items: center; gap: 8px; padding: 5px 2px;
|
||||
border-bottom: 1px solid #1a1a1a; }
|
||||
.vv-df-vm-row:last-of-type { border-bottom: none; }
|
||||
.vv-df-vm-icon { font-size: 15px; line-height: 1; flex-shrink: 0; }
|
||||
.vv-df-vm-meta { font-size: 10px; color: #555; }
|
||||
.vv-df-cols { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.vv-df-col { flex: 1; min-width: 0; }
|
||||
#vv-docker-folders-body { overflow-x: hidden; }
|
||||
.vv-df-folder { border-bottom: 1px solid #1a1a1a; }
|
||||
.vv-df-folder:last-child { border-bottom: none; }
|
||||
.vv-df-folder-hdr { display: flex; align-items: center; gap: 6px; padding: 5px 4px;
|
||||
cursor: pointer; user-select: none; border-radius: 3px; }
|
||||
.vv-df-folder-hdr:hover { background: rgba(255,255,255,0.03); }
|
||||
.vv-df-chevron { color: #555; font-size: 10px; width: 10px; flex-shrink: 0; }
|
||||
.vv-df-fname { flex: 1; font-size: 12px; color: #aaa; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-df-folder-body { padding-left: 10px; padding-bottom: 3px; }
|
||||
.vv-df-container { display: flex; align-items: center; gap: 7px; padding: 3px 6px;
|
||||
cursor: pointer; border-radius: 3px; user-select: none; }
|
||||
.vv-df-container:hover { background: rgba(255,255,255,0.04); }
|
||||
.vv-df-active { background: rgba(100,149,237,0.1) !important;
|
||||
outline: 1px solid rgba(100,149,237,0.35);
|
||||
box-shadow: inset 3px 0 0 #6495ed; }
|
||||
.vv-df-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.vv-df-cname { flex: 1; font-size: 12px; color: #ccc;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vv-df-status { font-size: 10px; color: #555; flex-shrink: 0; white-space: nowrap; }
|
||||
.vv-df-actions { display: flex; gap: 6px; padding: 3px 6px 5px 24px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Rsync standalone controls ────────────────────────────────────────────── */
|
||||
.vv-rsync-location { width: 120px; flex-shrink: 1; min-width: 60px; font-size: 11px; font-family: monospace;
|
||||
background: #111; border: 1px solid #333; color: #aaa;
|
||||
padding: 2px 6px; border-radius: 3px; }
|
||||
.vv-rsync-location:focus { border-color: #555; outline: none; }
|
||||
.vv-rsync-location::placeholder { color: #444; }
|
||||
.vv-script-args { flex: 1 1 0; min-width: 60px; max-width: 180px; font-size: 11px; font-family: monospace;
|
||||
background: #111; border: 1px solid #2a2a2a; color: #aaa;
|
||||
padding: 2px 6px; border-radius: 3px;
|
||||
opacity: 0.3; transition: opacity 0.15s; }
|
||||
.vv-script-args:hover, .vv-script-args:focus { opacity: 1; border-color: #555; outline: none; }
|
||||
.vv-script-args::placeholder { color: #3a3a3a; }
|
||||
.vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; }
|
||||
.vv-rsync-save-btn:hover { background: #22382a; }
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"Orchestrators\/array_started.sh": {
|
||||
"id": "Orchestrators\/array_started.sh",
|
||||
"enabled": true,
|
||||
"cron": "array_start",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/array_stopping.sh": {
|
||||
"id": "Orchestrators\/array_stopping.sh",
|
||||
"enabled": true,
|
||||
"cron": "array_stop",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/critical_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/critical_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "*\/30 * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/daily_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/daily_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 1 * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/intermediate_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/intermediate_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 *\/4 * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/monthly_maintenance.sh": {
|
||||
"id": "Orchestrators\/monthly_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 0 15 * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/sunday_morning_coffee_report.sh": {
|
||||
"id": "Orchestrators\/sunday_morning_coffee_report.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 7 * * 0",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/transcode_management.sh": {
|
||||
"id": "Orchestrators\/transcode_management.sh",
|
||||
"enabled": true,
|
||||
"cron": "*\/7 * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T20:57:55-04:00"
|
||||
},
|
||||
"Orchestrators\/watchdog_orchestrator.sh": {
|
||||
"id": "Orchestrators\/watchdog_orchestrator.sh",
|
||||
"enabled": true,
|
||||
"cron": "*\/15 * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T20:57:56-04:00"
|
||||
},
|
||||
"Orchestrators\/weekly_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/weekly_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "30 2 * * 0",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Tools\/api_cache_writer.sh": {
|
||||
"id": "Tools\/api_cache_writer.sh",
|
||||
"enabled": true,
|
||||
"cron": "* * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-03T00:00:00+00:00"
|
||||
},
|
||||
"Tools\/remote_arr_cache_writer.sh": {
|
||||
"id": "Tools\/remote_arr_cache_writer.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 *\/2 * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-03T00:00:00+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"Orchestrators\/array_started.sh": {
|
||||
"id": "Orchestrators\/array_started.sh",
|
||||
"enabled": true,
|
||||
"cron": "array_start",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/array_stopping.sh": {
|
||||
"id": "Orchestrators\/array_stopping.sh",
|
||||
"enabled": true,
|
||||
"cron": "array_stop",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/critical_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/critical_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "*\/30 * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/daily_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/daily_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 1 * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/intermediate_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/intermediate_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 *\/4 * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/monthly_maintenance.sh": {
|
||||
"id": "Orchestrators\/monthly_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 0 15 * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/sunday_morning_coffee_report.sh": {
|
||||
"id": "Orchestrators\/sunday_morning_coffee_report.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 7 * * 0",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/transcode_management.sh": {
|
||||
"id": "Orchestrators\/transcode_management.sh",
|
||||
"enabled": true,
|
||||
"cron": "*\/7 * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T20:57:55-04:00"
|
||||
},
|
||||
"Orchestrators\/watchdog_orchestrator.sh": {
|
||||
"id": "Orchestrators\/watchdog_orchestrator.sh",
|
||||
"enabled": true,
|
||||
"cron": "*\/15 * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T20:57:56-04:00"
|
||||
},
|
||||
"Orchestrators\/weekly_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/weekly_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "30 2 * * 0",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Plugin\/unraid\/tools\/api_cache_writer.sh": {
|
||||
"id": "Plugin\/unraid\/tools\/api_cache_writer.sh",
|
||||
"enabled": true,
|
||||
"cron": "* * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-05T00:00:00+00:00"
|
||||
},
|
||||
"Plugin\/unraid\/tools\/remote_arr_cache_writer.sh": {
|
||||
"id": "Plugin\/unraid\/tools\/remote_arr_cache_writer.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 *\/2 * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-05T00:00:00+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"Orchestrators\/array_started.sh": {
|
||||
"id": "Orchestrators\/array_started.sh",
|
||||
"enabled": true,
|
||||
"cron": "array_start",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/array_stopping.sh": {
|
||||
"id": "Orchestrators\/array_stopping.sh",
|
||||
"enabled": true,
|
||||
"cron": "array_stop",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/critical_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/critical_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "*\/30 * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/daily_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/daily_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 1 * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/intermediate_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/intermediate_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 *\/4 * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/monthly_maintenance.sh": {
|
||||
"id": "Orchestrators\/monthly_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 0 15 * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/sunday_morning_coffee_report.sh": {
|
||||
"id": "Orchestrators\/sunday_morning_coffee_report.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 7 * * 0",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Orchestrators\/transcode_management.sh": {
|
||||
"id": "Orchestrators\/transcode_management.sh",
|
||||
"enabled": true,
|
||||
"cron": "*\/7 * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T20:57:55-04:00"
|
||||
},
|
||||
"Orchestrators\/watchdog_orchestrator.sh": {
|
||||
"id": "Orchestrators\/watchdog_orchestrator.sh",
|
||||
"enabled": true,
|
||||
"cron": "*\/15 * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T20:57:56-04:00"
|
||||
},
|
||||
"Orchestrators\/weekly_sync_maintenance.sh": {
|
||||
"id": "Orchestrators\/weekly_sync_maintenance.sh",
|
||||
"enabled": true,
|
||||
"cron": "30 2 * * 0",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-01T00:00:00+00:00"
|
||||
},
|
||||
"Plugin\/unraid\/Tools\/api_cache_writer.sh": {
|
||||
"id": "Plugin\/unraid\/Tools\/api_cache_writer.sh",
|
||||
"enabled": true,
|
||||
"cron": "* * * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-05T00:00:00+00:00"
|
||||
},
|
||||
"Plugin\/unraid\/Tools\/remote_arr_cache_writer.sh": {
|
||||
"id": "Plugin\/unraid\/Tools\/remote_arr_cache_writer.sh",
|
||||
"enabled": true,
|
||||
"cron": "0 *\/2 * * *",
|
||||
"log_enabled": false,
|
||||
"updated": "2026-06-05T00:00:00+00:00"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Memory Index
|
||||
|
||||
- [Workspace](workspace.md) — primary workspace is /root; plugin lives at /boot/config/plugins/varaverk
|
||||
- [Feedback: Commits](feedback_commits.md) — no Co-Authored-By unless explicitly asked
|
||||
- [Project: Varaverk](project_varaverk.md) — self-healing two-server Unraid home media ecosystem
|
||||
- [User Profile](user_profile.md) — user context and preferences
|
||||
@@ -0,0 +1,7 @@
|
||||
# Memory Index
|
||||
|
||||
- [Workspace](workspace.md) — primary workspace is /root; plugin lives at /boot/config/plugins/varaverk
|
||||
- [Feedback: Commits](feedback_commits.md) — no Co-Authored-By unless explicitly asked
|
||||
- [Feedback: Dev vs Prod](feedback_dev_vs_prod.md) — only ever work in /boot/config/plugins/varaverk; dev folder is stale, ignore it
|
||||
- [Project: Varaverk](project_varaverk.md) — self-healing two-server Unraid home media ecosystem
|
||||
- [User Profile](user_profile.md) — user context and preferences
|
||||
@@ -0,0 +1,917 @@
|
||||
<style>
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
|
||||
.vv-au-toolbar { display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap; }
|
||||
.vv-au-title { font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;flex:1; }
|
||||
.vv-au-tab { font-size:11px;padding:3px 12px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#666;cursor:pointer;white-space:nowrap; }
|
||||
.vv-au-tab:hover{ background:#222;color:#aaa; }
|
||||
.vv-au-tab.active { background:#0d1f2a;border-color:#1a3a5a;color:#5c9fd4; }
|
||||
.vv-au-btn { font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#888;cursor:pointer;white-space:nowrap; }
|
||||
.vv-au-btn:hover{ background:#222;color:#bbb; }
|
||||
.vv-au-btn.prim { border-color:#1a3a5a;background:#0d1f2a;color:#5c9fd4; }
|
||||
.vv-au-btn.warn { border-color:#3a2000;background:#1f1200;color:#ff9800; }
|
||||
.vv-au-btn.danger{border-color:#3a1a1a;background:#200d0d;color:#ef5350; }
|
||||
.vv-au-btn.green{ border-color:#1a3a1a;background:#0d1f0d;color:#4caf50; }
|
||||
.vv-au-btn:disabled { opacity:.4;cursor:default; }
|
||||
|
||||
/* ── Panels ──────────────────────────────────────────────────────────────── */
|
||||
.vv-au-panel { display:none; }
|
||||
.vv-au-panel.active { display:block; }
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────────────── */
|
||||
.vv-au-tbl { width:100%;border-collapse:collapse;font-size:11px; }
|
||||
.vv-au-tbl th { text-align:left;padding:5px 10px;font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid #1e1e1e;white-space:nowrap; }
|
||||
.vv-au-tbl td { padding:7px 10px;border-bottom:1px solid #161616;vertical-align:middle; }
|
||||
.vv-au-tbl tr:hover td { background:#141414; }
|
||||
.vv-au-tbl tr:last-child td { border-bottom:none; }
|
||||
|
||||
/* ── Badges ──────────────────────────────────────────────────────────────── */
|
||||
.vv-au-badge { font-size:10px;padding:1px 6px;border-radius:2px;white-space:nowrap;display:inline-block; }
|
||||
.vv-au-badge.ssl { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
|
||||
.vv-au-badge.nossl { background:#111;border:1px solid #222;color:#444; }
|
||||
.vv-au-badge.on { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
|
||||
.vv-au-badge.off { background:#1a0a0a;border:1px solid #2a1a1a;color:#555; }
|
||||
.vv-au-badge.bypass { background:#0a1a2a;border:1px solid #1a3a5a;color:#5c9fd4; }
|
||||
.vv-au-badge.one_factor { background:#1a1a0a;border:1px solid #3a3a1a;color:#cddc39; }
|
||||
.vv-au-badge.two_factor { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
|
||||
.vv-au-badge.deny { background:#1a0a0a;border:1px solid #3a1a1a;color:#ef5350; }
|
||||
.vv-au-badge.grp { background:#1a0d2a;border:1px solid #2a1a4a;color:#9c6ff7;margin:1px 2px; }
|
||||
.vv-au-badge.host { background:#111;border:1px solid #1e1e1e;color:#444; }
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────────────────────── */
|
||||
.vv-au-card { background:#161616;border:1px solid #222;border-radius:6px;overflow:hidden; }
|
||||
.vv-au-card-h { display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid #1e1e1e;background:#111; }
|
||||
.vv-au-card-title { font-size:11px;font-weight:bold;color:#666;text-transform:uppercase;letter-spacing:.06em;flex:1; }
|
||||
|
||||
/* ── Users / Groups split ────────────────────────────────────────────────── */
|
||||
.vv-au-ug-grid { display:grid;grid-template-columns:3fr 2fr;gap:12px; }
|
||||
@media (max-width:700px) { .vv-au-ug-grid { grid-template-columns:1fr; } }
|
||||
|
||||
.vv-au-user-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;display:flex;align-items:center;gap:8px;flex-wrap:wrap; }
|
||||
.vv-au-user-row:last-child { border-bottom:none; }
|
||||
.vv-au-user-row:hover { background:#141414; }
|
||||
.vv-au-user-name { font-size:12px;color:#bbb;font-weight:bold;min-width:80px; }
|
||||
.vv-au-user-email{ font-size:10px;color:#444;flex:1; }
|
||||
.vv-au-user-acts { display:flex;gap:4px;margin-left:auto;flex-shrink:0; }
|
||||
|
||||
.vv-au-grp-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;cursor:pointer; }
|
||||
.vv-au-grp-row:last-child { border-bottom:none; }
|
||||
.vv-au-grp-row:hover { background:#141414; }
|
||||
.vv-au-grp-name { font-size:12px;color:#bbb;font-weight:bold; }
|
||||
.vv-au-grp-cnt { font-size:10px;color:#444;margin-top:2px; }
|
||||
.vv-au-grp-acts { float:right;display:flex;gap:4px;margin-top:1px; }
|
||||
|
||||
.vv-au-grp-members { padding:6px 12px 10px 20px;background:#0f0f0f;border-bottom:1px solid #1a1a1a; display:none; }
|
||||
.vv-au-grp-members.open { display:block; }
|
||||
.vv-au-grp-member { font-size:11px;color:#666;padding:2px 0;display:flex;align-items:center;gap:6px; }
|
||||
.vv-au-grp-member-rm { font-size:11px;color:#3a1a1a;cursor:pointer;padding:0 2px; }
|
||||
.vv-au-grp-member-rm:hover { color:#ef5350; }
|
||||
|
||||
/* ── Access Control ──────────────────────────────────────────────────────── */
|
||||
.vv-au-ac-defpol { display:flex;align-items:center;gap:10px;padding:8px 12px;background:#111;border:1px solid #222;border-radius:4px;margin-bottom:10px;font-size:11px;color:#555; }
|
||||
.vv-au-ac-defpol select { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#888;font-size:11px;padding:2px 6px; }
|
||||
.vv-au-rule-num { color:#333;font-size:10px;width:24px;text-align:right;flex-shrink:0; }
|
||||
.vv-au-rule-acts { display:flex;gap:3px;white-space:nowrap; }
|
||||
.vv-au-icon-btn { font-size:12px;color:#444;cursor:pointer;padding:1px 3px;border-radius:2px;background:none;border:none;line-height:1; }
|
||||
.vv-au-icon-btn:hover { color:#bbb;background:#222; }
|
||||
.vv-au-icon-btn.del:hover { color:#ef5350;background:#1a0808; }
|
||||
|
||||
/* ── Section toolbar (within panel) ─────────────────────────────────────── */
|
||||
.vv-au-sec-bar { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
|
||||
.vv-au-sec-title{ font-size:11px;color:#555;flex:1; }
|
||||
|
||||
/* ── Toggle switch ───────────────────────────────────────────────────────── */
|
||||
.vv-au-tog { width:28px;height:16px;border-radius:8px;background:#1e1e1e;border:1px solid #2a2a2a;position:relative;cursor:pointer;display:inline-block;flex-shrink:0; }
|
||||
.vv-au-tog.on { background:#1a3a1a;border-color:#2d5a2d; }
|
||||
.vv-au-tog::after { content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#444;transition:left .12s,background .12s; }
|
||||
.vv-au-tog.on::after { left:14px;background:#4caf50; }
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
||||
.vv-au-overlay { position:fixed;inset:0;background:#0009;z-index:9000;display:none;align-items:center;justify-content:center; }
|
||||
.vv-au-overlay.open { display:flex; }
|
||||
.vv-au-modal { background:#181818;border:1px solid #2a2a2a;border-radius:6px;padding:18px 20px;min-width:360px;max-width:520px;width:90vw;max-height:85vh;overflow-y:auto; }
|
||||
.vv-au-modal h3 { font-size:12px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;margin:0 0 14px; }
|
||||
.vv-au-field { margin-bottom:10px; }
|
||||
.vv-au-label { display:block;font-size:10px;color:#555;margin-bottom:3px;text-transform:uppercase;letter-spacing:.04em; }
|
||||
.vv-au-input { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #252525;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
|
||||
.vv-au-input:focus { border-color:#1a3a5a; }
|
||||
.vv-au-select { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #252525;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
|
||||
.vv-au-hint { font-size:10px;color:#333;margin-top:2px; }
|
||||
.vv-au-modal-acts { display:flex;justify-content:flex-end;gap:8px;margin-top:16px; }
|
||||
.vv-au-tog-row { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
|
||||
.vv-au-tog-lbl { font-size:11px;color:#666; }
|
||||
.vv-au-adv-toggle { font-size:10px;color:#333;cursor:pointer;margin-bottom:8px; }
|
||||
.vv-au-adv-toggle:hover { color:#666; }
|
||||
.vv-au-adv-section { display:none; }
|
||||
.vv-au-adv-section.open { display:block; }
|
||||
.vv-au-err { font-size:11px;color:#ef5350;margin-top:8px;display:none; }
|
||||
.vv-au-err.show { display:block; }
|
||||
|
||||
/* ── Misc ────────────────────────────────────────────────────────────────── */
|
||||
.vv-au-empty { padding:24px;text-align:center;font-size:11px;color:#333; }
|
||||
.vv-au-domain { font-size:12px;color:#bbb;font-weight:bold; }
|
||||
.vv-au-fwd { font-size:10px;color:#444; }
|
||||
.vv-au-loading { color:#333;font-size:11px;padding:16px;text-align:center; }
|
||||
</style>
|
||||
|
||||
<?php
|
||||
require_once __DIR__ . '/../include/auth.php';
|
||||
$isOwner = vv_is_owner();
|
||||
?>
|
||||
|
||||
<div class="vv-au-toolbar">
|
||||
<span class="vv-au-title">Auth Stack</span>
|
||||
<button class="vv-au-tab active" data-tab="proxies">Proxies</button>
|
||||
<button class="vv-au-tab" data-tab="users">Users & Groups</button>
|
||||
<button class="vv-au-tab" data-tab="acl">Access Control</button>
|
||||
<button class="vv-au-btn" id="vv-au-refresh" title="Refresh current tab">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Proxies ─────────────────────────────────────────────────────────────── -->
|
||||
<div class="vv-au-panel active" id="vv-au-panel-proxies">
|
||||
<div class="vv-au-sec-bar">
|
||||
<span class="vv-au-sec-title" id="vv-au-proxy-count"></span>
|
||||
<button class="vv-au-btn prim" id="vv-au-proxy-add">+ Add Proxy</button>
|
||||
</div>
|
||||
<div class="vv-au-card">
|
||||
<div class="vv-au-loading" id="vv-au-proxy-loading">Loading…</div>
|
||||
<table class="vv-au-tbl" id="vv-au-proxy-tbl" style="display:none">
|
||||
<thead><tr>
|
||||
<th>Domain</th><th>Forward</th><th>SSL</th><th>Status</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="vv-au-proxy-body"></tbody>
|
||||
</table>
|
||||
<div class="vv-au-empty" id="vv-au-proxy-empty" style="display:none">No proxy hosts configured.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Users & Groups ─────────────────────────────────────────────────────── -->
|
||||
<div class="vv-au-panel" id="vv-au-panel-users">
|
||||
<div class="vv-au-ug-grid">
|
||||
|
||||
<div class="vv-au-card">
|
||||
<div class="vv-au-card-h">
|
||||
<span class="vv-au-card-title">Users</span>
|
||||
<button class="vv-au-btn prim" id="vv-au-user-add">+ Add User</button>
|
||||
</div>
|
||||
<div class="vv-au-loading" id="vv-au-users-loading">Loading…</div>
|
||||
<div id="vv-au-users-list"></div>
|
||||
<div class="vv-au-empty" id="vv-au-users-empty" style="display:none">No users found.</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-au-card">
|
||||
<div class="vv-au-card-h">
|
||||
<span class="vv-au-card-title">Groups</span>
|
||||
<button class="vv-au-btn prim" id="vv-au-group-add">+ Add Group</button>
|
||||
</div>
|
||||
<div class="vv-au-loading" id="vv-au-groups-loading">Loading…</div>
|
||||
<div id="vv-au-groups-list"></div>
|
||||
<div class="vv-au-empty" id="vv-au-groups-empty" style="display:none">No groups found.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Access Control ─────────────────────────────────────────────────────── -->
|
||||
<div class="vv-au-panel" id="vv-au-panel-acl">
|
||||
<div class="vv-au-ac-defpol" id="vv-au-ac-defpol-bar">
|
||||
<span>Default policy:</span>
|
||||
<select id="vv-au-ac-defpol" <?= $isOwner ? '' : 'disabled' ?>>
|
||||
<option value="deny">deny</option>
|
||||
<option value="two_factor">two_factor</option>
|
||||
<option value="one_factor">one_factor</option>
|
||||
<option value="bypass">bypass</option>
|
||||
</select>
|
||||
<?php if (!$isOwner): ?>
|
||||
<span style="font-size:10px;color:#3a2a1a;">default policy editable on HOST1</span>
|
||||
<?php endif; ?>
|
||||
<button class="vv-au-btn green" id="vv-au-ac-save" style="margin-left:auto">Save & Restart Authelia</button>
|
||||
</div>
|
||||
<div class="vv-au-sec-bar">
|
||||
<span class="vv-au-sec-title" id="vv-au-acl-count"></span>
|
||||
<button class="vv-au-btn prim" id="vv-au-rule-add">+ Add Rule</button>
|
||||
</div>
|
||||
<div class="vv-au-card">
|
||||
<div class="vv-au-loading" id="vv-au-acl-loading">Loading…</div>
|
||||
<table class="vv-au-tbl" id="vv-au-acl-tbl" style="display:none">
|
||||
<thead><tr>
|
||||
<th style="width:24px">#</th>
|
||||
<th>Domain</th><th>Policy</th><th>Subject</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="vv-au-acl-body"></tbody>
|
||||
</table>
|
||||
<div class="vv-au-empty" id="vv-au-acl-empty" style="display:none">No rules configured.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Modal overlay ──────────────────────────────────────────────────────── -->
|
||||
<div class="vv-au-overlay" id="vv-au-overlay">
|
||||
<div class="vv-au-modal" id="vv-au-modal"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const API = '/plugins/varaverk/api/auth.php';
|
||||
const IS_OWNER = <?= $isOwner ? 'true' : 'false' ?>;
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
let _proxies = [], _certs = [];
|
||||
let _users = [], _groups = [];
|
||||
let _rules = [], _defaultPolicy = 'deny';
|
||||
let _activeTab = 'proxies';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function _esc(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function _get(action, cb) {
|
||||
fetch(API + '?action=' + action)
|
||||
.then(r => r.json()).then(cb)
|
||||
.catch(e => cb({ ok: false, error: String(e) }));
|
||||
}
|
||||
|
||||
function _post(params, cb) {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(params)) fd.append(k, v);
|
||||
fetch(API, { method: 'POST', body: fd })
|
||||
.then(r => r.json()).then(cb)
|
||||
.catch(e => cb({ ok: false, error: String(e) }));
|
||||
}
|
||||
|
||||
function _modal(html) {
|
||||
document.getElementById('vv-au-modal').innerHTML = html;
|
||||
document.getElementById('vv-au-overlay').classList.add('open');
|
||||
}
|
||||
function _closeModal() {
|
||||
document.getElementById('vv-au-overlay').classList.remove('open');
|
||||
}
|
||||
|
||||
function _togHtml(id, on, title) {
|
||||
return `<span class="vv-au-tog${on?' on':''}" data-tog="${_esc(id)}" title="${_esc(title)}"></span>`;
|
||||
}
|
||||
|
||||
function _policyBadge(p) {
|
||||
return `<span class="vv-au-badge ${_esc(p)}">${_esc(p)}</span>`;
|
||||
}
|
||||
|
||||
function _normSubject(val) {
|
||||
if (!val) return [];
|
||||
if (Array.isArray(val)) return val;
|
||||
return [val];
|
||||
}
|
||||
|
||||
function _normDomain(val) {
|
||||
if (!val) return [];
|
||||
if (Array.isArray(val)) return val;
|
||||
return [val];
|
||||
}
|
||||
|
||||
// ── Tab switching ─────────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.vv-au-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tab = btn.dataset.tab;
|
||||
document.querySelectorAll('.vv-au-tab').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.vv-au-panel').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('vv-au-panel-' + tab).classList.add('active');
|
||||
_activeTab = tab;
|
||||
_loadTab(tab);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('vv-au-refresh').addEventListener('click', () => _loadTab(_activeTab));
|
||||
|
||||
function _loadTab(tab) {
|
||||
if (tab === 'proxies') _loadProxies();
|
||||
if (tab === 'users') { _loadUsers(); _loadGroups(); }
|
||||
if (tab === 'acl') _loadAcl();
|
||||
}
|
||||
|
||||
// ── Proxies ───────────────────────────────────────────────────────────────────
|
||||
function _loadProxies() {
|
||||
const loading = document.getElementById('vv-au-proxy-loading');
|
||||
const tbl = document.getElementById('vv-au-proxy-tbl');
|
||||
const empty = document.getElementById('vv-au-proxy-empty');
|
||||
loading.style.display = 'block';
|
||||
tbl.style.display = 'none';
|
||||
empty.style.display = 'none';
|
||||
|
||||
// Load certs and proxies in parallel
|
||||
let certsLoaded = false, proxiesLoaded = false;
|
||||
function _check() {
|
||||
if (!certsLoaded || !proxiesLoaded) return;
|
||||
loading.style.display = 'none';
|
||||
if (!_proxies.length) { empty.style.display = 'block'; return; }
|
||||
tbl.style.display = 'table';
|
||||
_renderProxies();
|
||||
}
|
||||
|
||||
_get('npm_certs', r => { _certs = r.certs || []; certsLoaded = true; _check(); });
|
||||
_get('npm_proxies', r => {
|
||||
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350">'+_esc(r.error)+'</span>'; return; }
|
||||
_proxies = r.proxies || [];
|
||||
proxiesLoaded = true;
|
||||
_check();
|
||||
});
|
||||
}
|
||||
|
||||
function _renderProxies() {
|
||||
const count = document.getElementById('vv-au-proxy-count');
|
||||
count.textContent = _proxies.length + ' host' + (_proxies.length !== 1 ? 's' : '');
|
||||
|
||||
const body = document.getElementById('vv-au-proxy-body');
|
||||
body.innerHTML = _proxies.map(p => {
|
||||
const domains = (p.domain_names || []).join(', ');
|
||||
const fwd = p.forward_scheme + '://' + p.forward_host + ':' + p.forward_port;
|
||||
const hasSsl = p.certificate_id && p.certificate_id !== '0';
|
||||
const sslBadge = hasSsl
|
||||
? `<span class="vv-au-badge ssl">SSL</span>`
|
||||
: `<span class="vv-au-badge nossl">none</span>`;
|
||||
const enabled = p.enabled;
|
||||
return `<tr>
|
||||
<td><div class="vv-au-domain">${_esc(domains)}</div></td>
|
||||
<td><div class="vv-au-fwd">${_esc(fwd)}</div></td>
|
||||
<td>${sslBadge}</td>
|
||||
<td>${_togHtml('proxy-' + p.id, enabled, enabled ? 'Enabled — click to disable' : 'Disabled — click to enable')}</td>
|
||||
<td style="text-align:right">
|
||||
<div class="vv-au-rule-acts">
|
||||
<button class="vv-au-icon-btn" data-proxy-edit="${p.id}" title="Edit">✎</button>
|
||||
<button class="vv-au-icon-btn del" data-proxy-del="${p.id}" title="Delete">✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _proxyModal(id) {
|
||||
const p = id ? _proxies.find(x => x.id === id) : null;
|
||||
const certOptions = _certs.map(c =>
|
||||
`<option value="${c.id}"${p && p.certificate_id == c.id ? ' selected' : ''}>${_esc(c.nice_name || c.domain_names?.join(', '))}</option>`
|
||||
).join('');
|
||||
|
||||
_modal(`<h3>${p ? 'Edit Proxy' : 'Add Proxy'}</h3>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Domain Names</label>
|
||||
<input class="vv-au-input" id="pm-domains" value="${_esc((p?.domain_names||[]).join(', '))}" placeholder="example.com, *.example.com">
|
||||
<div class="vv-au-hint">Comma-separated</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr 80px;gap:8px">
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Scheme</label>
|
||||
<select class="vv-au-select" id="pm-scheme">
|
||||
<option${(!p||p.forward_scheme==='http')?' selected':''}>http</option>
|
||||
<option${(p?.forward_scheme==='https')?' selected':''}>https</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Forward Host</label>
|
||||
<input class="vv-au-input" id="pm-host" value="${_esc(p?.forward_host||'')}" placeholder="192.168.1.100">
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Port</label>
|
||||
<input class="vv-au-input" id="pm-port" type="number" value="${_esc(p?.forward_port||80)}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">SSL Certificate</label>
|
||||
<select class="vv-au-select" id="pm-cert">
|
||||
<option value="0">None</option>
|
||||
${certOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="vv-au-tog-row">
|
||||
<span class="vv-au-tog${p?.ssl_forced?' on':''}" id="pm-ssl-forced"></span>
|
||||
<span class="vv-au-tog-lbl">Force SSL</span>
|
||||
</div>
|
||||
<div class="vv-au-tog-row">
|
||||
<span class="vv-au-tog${p?.block_exploits?' on':''}" id="pm-block-exploits"></span>
|
||||
<span class="vv-au-tog-lbl">Block Common Exploits</span>
|
||||
</div>
|
||||
<div class="vv-au-tog-row">
|
||||
<span class="vv-au-tog${(!p||p.allow_websocket_upgrade!==false)?' on':''}" id="pm-websocket"></span>
|
||||
<span class="vv-au-tog-lbl">WebSocket Support</span>
|
||||
</div>
|
||||
<div class="vv-au-err" id="pm-err"></div>
|
||||
<div class="vv-au-modal-acts">
|
||||
<button class="vv-au-btn" id="pm-cancel">Cancel</button>
|
||||
<button class="vv-au-btn prim" id="pm-save">${p ? 'Save' : 'Create'}</button>
|
||||
</div>`);
|
||||
|
||||
// Wire inline toggles
|
||||
document.querySelectorAll('#vv-au-modal .vv-au-tog').forEach(t => {
|
||||
t.addEventListener('click', () => t.classList.toggle('on'));
|
||||
});
|
||||
|
||||
document.getElementById('pm-cancel').onclick = _closeModal;
|
||||
document.getElementById('pm-save').onclick = () => {
|
||||
const domains = document.getElementById('pm-domains').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||||
if (!domains.length) { _showModalErr('pm-err', 'Domain required'); return; }
|
||||
const host = document.getElementById('pm-host').value.trim();
|
||||
if (!host) { _showModalErr('pm-err', 'Forward host required'); return; }
|
||||
|
||||
const data = {
|
||||
domain_names: domains,
|
||||
forward_scheme: document.getElementById('pm-scheme').value,
|
||||
forward_host: host,
|
||||
forward_port: parseInt(document.getElementById('pm-port').value) || 80,
|
||||
certificate_id: parseInt(document.getElementById('pm-cert').value) || 0,
|
||||
ssl_forced: document.getElementById('pm-ssl-forced').classList.contains('on'),
|
||||
block_exploits: document.getElementById('pm-block-exploits').classList.contains('on'),
|
||||
allow_websocket_upgrade: document.getElementById('pm-websocket').classList.contains('on'),
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
meta: {},
|
||||
locations: [],
|
||||
advanced_config: '',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const btn = document.getElementById('pm-save');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
|
||||
const done = r => {
|
||||
if (!r.ok) { _showModalErr('pm-err', r.error||'Save failed'); btn.disabled=false; btn.textContent = p?'Save':'Create'; return; }
|
||||
_closeModal(); _loadProxies();
|
||||
};
|
||||
if (p) _post({ action:'npm_update', id: p.id, data: JSON.stringify(data) }, done);
|
||||
else _post({ action:'npm_create', data: JSON.stringify(data) }, done);
|
||||
};
|
||||
}
|
||||
|
||||
function _showModalErr(id, msg) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.textContent = msg; el.classList.add('show'); }
|
||||
}
|
||||
|
||||
// Proxy event delegation
|
||||
document.getElementById('vv-au-panel-proxies').addEventListener('click', e => {
|
||||
// Add button
|
||||
if (e.target.id === 'vv-au-proxy-add') { _proxyModal(null); return; }
|
||||
// Toggle
|
||||
const tog = e.target.closest('[data-tog^="proxy-"]');
|
||||
if (tog) {
|
||||
const id = parseInt(tog.dataset.tog.split('-')[1]);
|
||||
const was = tog.classList.contains('on');
|
||||
tog.classList.toggle('on');
|
||||
_post({ action:'npm_toggle', id, enabled: was?'0':'1' }, r => {
|
||||
if (!r.ok) { tog.classList.toggle('on'); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Edit
|
||||
const editBtn = e.target.closest('[data-proxy-edit]');
|
||||
if (editBtn) { _proxyModal(parseInt(editBtn.dataset.proxyEdit)); return; }
|
||||
// Delete
|
||||
const delBtn = e.target.closest('[data-proxy-del]');
|
||||
if (delBtn) {
|
||||
const id = parseInt(delBtn.dataset.proxyDel);
|
||||
const p = _proxies.find(x => x.id === id);
|
||||
if (!confirm('Delete proxy for ' + (p?.domain_names||['this host']).join(', ') + '?')) return;
|
||||
_post({ action:'npm_delete', id }, r => { if (r.ok) _loadProxies(); });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
function _loadUsers() {
|
||||
const loading = document.getElementById('vv-au-users-loading');
|
||||
const list = document.getElementById('vv-au-users-list');
|
||||
const empty = document.getElementById('vv-au-users-empty');
|
||||
loading.style.display = 'block';
|
||||
list.innerHTML = '';
|
||||
empty.style.display = 'none';
|
||||
|
||||
_get('lldap_users', r => {
|
||||
loading.style.display = 'none';
|
||||
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
|
||||
_users = r.users || [];
|
||||
if (!_users.length) { empty.style.display = 'block'; return; }
|
||||
list.innerHTML = _users.map(u => {
|
||||
const grpBadges = (u.groups||[]).map(g => `<span class="vv-au-badge grp" title="Click to remove" data-rm-from-group="${_esc(u.id)}" data-gid="${g.id}">${_esc(g.displayName)}</span>`).join('');
|
||||
return `<div class="vv-au-user-row">
|
||||
<span class="vv-au-user-name">${_esc(u.displayName||u.id)}</span>
|
||||
<span class="vv-au-user-email">${_esc(u.email||'')}</span>
|
||||
<div style="display:flex;gap:3px;align-items:center;flex-wrap:wrap">${grpBadges}</div>
|
||||
<div class="vv-au-user-acts">
|
||||
<button class="vv-au-icon-btn" data-user-grp="${_esc(u.id)}" title="Add to group">+grp</button>
|
||||
<button class="vv-au-icon-btn" data-user-pass="${_esc(u.id)}" title="Change password">🔑</button>
|
||||
<button class="vv-au-icon-btn" data-user-edit="${_esc(u.id)}" title="Edit">✎</button>
|
||||
<button class="vv-au-icon-btn del" data-user-del="${_esc(u.id)}" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
function _userModal(uid) {
|
||||
const u = uid ? _users.find(x => x.id === uid) : null;
|
||||
_modal(`<h3>${u ? 'Edit User' : 'Add User'}</h3>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Username (ID)</label>
|
||||
<input class="vv-au-input" id="um-uid" value="${_esc(u?.id||'')}" ${u?'readonly':''} placeholder="johndoe">
|
||||
${u ? '' : '<div class="vv-au-hint">Lowercase letters, digits, hyphens — cannot be changed later</div>'}
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Display Name</label>
|
||||
<input class="vv-au-input" id="um-name" value="${_esc(u?.displayName||'')}" placeholder="John Doe">
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Email</label>
|
||||
<input class="vv-au-input" id="um-email" type="email" value="${_esc(u?.email||'')}" placeholder="john@example.com">
|
||||
</div>
|
||||
${!u ? `<div class="vv-au-field">
|
||||
<label class="vv-au-label">Password</label>
|
||||
<input class="vv-au-input" id="um-pass" type="password" placeholder="Initial password">
|
||||
</div>` : ''}
|
||||
<div class="vv-au-err" id="um-err"></div>
|
||||
<div class="vv-au-modal-acts">
|
||||
<button class="vv-au-btn" id="um-cancel">Cancel</button>
|
||||
<button class="vv-au-btn prim" id="um-save">${u ? 'Save' : 'Create'}</button>
|
||||
</div>`);
|
||||
|
||||
document.getElementById('um-cancel').onclick = _closeModal;
|
||||
document.getElementById('um-save').onclick = () => {
|
||||
const id = (document.getElementById('um-uid').value||'').trim();
|
||||
const name = (document.getElementById('um-name').value||'').trim();
|
||||
const email= (document.getElementById('um-email').value||'').trim();
|
||||
const pass = document.getElementById('um-pass')?.value || '';
|
||||
if (!id) { _showModalErr('um-err','Username required'); return; }
|
||||
if (!email){ _showModalErr('um-err','Email required'); return; }
|
||||
|
||||
const btn = document.getElementById('um-save');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
|
||||
const done = r => {
|
||||
if (!r.ok) { _showModalErr('um-err', r.error||'Save failed'); btn.disabled=false; btn.textContent=u?'Save':'Create'; return; }
|
||||
_closeModal(); _loadUsers();
|
||||
};
|
||||
if (u) _post({ action:'lldap_update_user', uid:id, email, display_name:name }, done);
|
||||
else _post({ action:'lldap_create_user', uid:id, email, display_name:name, password:pass }, done);
|
||||
};
|
||||
}
|
||||
|
||||
function _passModal(uid) {
|
||||
_modal(`<h3>Change Password</h3>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">User</label>
|
||||
<input class="vv-au-input" value="${_esc(uid)}" readonly>
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">New Password</label>
|
||||
<input class="vv-au-input" id="pw-pass" type="password" placeholder="New password" autofocus>
|
||||
</div>
|
||||
<div class="vv-au-err" id="pw-err"></div>
|
||||
<div class="vv-au-modal-acts">
|
||||
<button class="vv-au-btn" id="pw-cancel">Cancel</button>
|
||||
<button class="vv-au-btn prim" id="pw-save">Set Password</button>
|
||||
</div>`);
|
||||
document.getElementById('pw-cancel').onclick = _closeModal;
|
||||
document.getElementById('pw-save').onclick = () => {
|
||||
const pass = document.getElementById('pw-pass').value;
|
||||
if (!pass) { _showModalErr('pw-err','Password required'); return; }
|
||||
const btn = document.getElementById('pw-save');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
_post({ action:'lldap_set_password', uid, password:pass }, r => {
|
||||
if (!r.ok) { _showModalErr('pw-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Set Password'; return; }
|
||||
_closeModal();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function _addToGroupModal(uid) {
|
||||
const user = _users.find(u => u.id === uid);
|
||||
const userGids= new Set((user?.groups||[]).map(g => g.id));
|
||||
const available = _groups.filter(g => !userGids.has(g.id));
|
||||
if (!available.length) { alert('User is already in all groups.'); return; }
|
||||
const opts = available.map(g => `<option value="${g.id}">${_esc(g.displayName)}</option>`).join('');
|
||||
_modal(`<h3>Add to Group</h3>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">User</label>
|
||||
<input class="vv-au-input" value="${_esc(user?.displayName||uid)}" readonly>
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Group</label>
|
||||
<select class="vv-au-select" id="ag-grp">${opts}</select>
|
||||
</div>
|
||||
<div class="vv-au-err" id="ag-err"></div>
|
||||
<div class="vv-au-modal-acts">
|
||||
<button class="vv-au-btn" id="ag-cancel">Cancel</button>
|
||||
<button class="vv-au-btn prim" id="ag-save">Add</button>
|
||||
</div>`);
|
||||
document.getElementById('ag-cancel').onclick = _closeModal;
|
||||
document.getElementById('ag-save').onclick = () => {
|
||||
const gid = parseInt(document.getElementById('ag-grp').value);
|
||||
const btn = document.getElementById('ag-save');
|
||||
btn.disabled = true; btn.textContent = 'Adding…';
|
||||
_post({ action:'lldap_add_to_group', uid, gid }, r => {
|
||||
if (!r.ok) { _showModalErr('ag-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Add'; return; }
|
||||
_closeModal(); _loadUsers(); _loadGroups();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// User event delegation
|
||||
document.getElementById('vv-au-panel-users').addEventListener('click', e => {
|
||||
if (e.target.id === 'vv-au-user-add') { _userModal(null); return; }
|
||||
|
||||
const editBtn = e.target.closest('[data-user-edit]');
|
||||
if (editBtn) { _userModal(editBtn.dataset.userEdit); return; }
|
||||
|
||||
const passBtn = e.target.closest('[data-user-pass]');
|
||||
if (passBtn) { _passModal(passBtn.dataset.userPass); return; }
|
||||
|
||||
const grpBtn = e.target.closest('[data-user-grp]');
|
||||
if (grpBtn) { _addToGroupModal(grpBtn.dataset.userGrp); return; }
|
||||
|
||||
const delBtn = e.target.closest('[data-user-del]');
|
||||
if (delBtn) {
|
||||
const uid = delBtn.dataset.userDel;
|
||||
const user = _users.find(u => u.id === uid);
|
||||
if (!confirm('Delete user "' + (user?.displayName||uid) + '"?')) return;
|
||||
_post({ action:'lldap_delete_user', uid }, r => { if (r.ok) _loadUsers(); });
|
||||
return;
|
||||
}
|
||||
|
||||
const rmBadge = e.target.closest('[data-rm-from-group]');
|
||||
if (rmBadge) {
|
||||
const uid = rmBadge.dataset.rmFromGroup;
|
||||
const gid = parseInt(rmBadge.dataset.gid);
|
||||
const grp = _groups.find(g => g.id === gid);
|
||||
if (!confirm('Remove from group "' + (grp?.displayName||gid) + '"?')) return;
|
||||
_post({ action:'lldap_remove_from_group', uid, gid }, r => { if (r.ok) { _loadUsers(); _loadGroups(); } });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────────
|
||||
function _loadGroups() {
|
||||
const loading = document.getElementById('vv-au-groups-loading');
|
||||
const list = document.getElementById('vv-au-groups-list');
|
||||
const empty = document.getElementById('vv-au-groups-empty');
|
||||
loading.style.display = 'block';
|
||||
list.innerHTML = '';
|
||||
empty.style.display = 'none';
|
||||
|
||||
_get('lldap_groups', r => {
|
||||
loading.style.display = 'none';
|
||||
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
|
||||
_groups = r.groups || [];
|
||||
if (!_groups.length) { empty.style.display = 'block'; return; }
|
||||
list.innerHTML = _groups.map(g => {
|
||||
const members = (g.users||[]).map(u =>
|
||||
`<div class="vv-au-grp-member">
|
||||
<span>${_esc(u.displayName||u.id)}</span>
|
||||
<span class="vv-au-grp-member-rm" data-rm-user="${_esc(u.id)}" data-gid="${g.id}" title="Remove from group">✕</span>
|
||||
</div>`
|
||||
).join('');
|
||||
return `<div class="vv-au-grp-row" data-grp="${g.id}">
|
||||
<div class="vv-au-grp-acts">
|
||||
<button class="vv-au-icon-btn del" data-group-del="${g.id}" title="Delete group">✕</button>
|
||||
</div>
|
||||
<div class="vv-au-grp-name">${_esc(g.displayName)}</div>
|
||||
<div class="vv-au-grp-cnt">${(g.users||[]).length} member${(g.users||[]).length!==1?'s':''}</div>
|
||||
</div>
|
||||
<div class="vv-au-grp-members" id="gm-${g.id}">
|
||||
${members || '<div style="font-size:11px;color:#333;padding:2px 0">No members</div>'}
|
||||
</div>`;
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
// Group event delegation
|
||||
document.getElementById('vv-au-panel-users').addEventListener('click', e => {
|
||||
if (e.target.id === 'vv-au-group-add') {
|
||||
_modal(`<h3>Add Group</h3>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Group Name</label>
|
||||
<input class="vv-au-input" id="gm-name" placeholder="admins" autofocus>
|
||||
</div>
|
||||
<div class="vv-au-err" id="gm-err"></div>
|
||||
<div class="vv-au-modal-acts">
|
||||
<button class="vv-au-btn" id="gm-cancel">Cancel</button>
|
||||
<button class="vv-au-btn prim" id="gm-save">Create</button>
|
||||
</div>`);
|
||||
document.getElementById('gm-cancel').onclick = _closeModal;
|
||||
document.getElementById('gm-save').onclick = () => {
|
||||
const name = (document.getElementById('gm-name').value||'').trim();
|
||||
if (!name) { _showModalErr('gm-err','Name required'); return; }
|
||||
const btn = document.getElementById('gm-save');
|
||||
btn.disabled = true; btn.textContent = 'Creating…';
|
||||
_post({ action:'lldap_create_group', name }, r => {
|
||||
if (!r.ok) { _showModalErr('gm-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Create'; return; }
|
||||
_closeModal(); _loadGroups();
|
||||
});
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const grpRow = e.target.closest('[data-grp]');
|
||||
if (grpRow && !e.target.closest('button') && !e.target.closest('.vv-au-icon-btn')) {
|
||||
const panel = document.getElementById('gm-' + grpRow.dataset.grp);
|
||||
if (panel) panel.classList.toggle('open');
|
||||
return;
|
||||
}
|
||||
|
||||
const delGrp = e.target.closest('[data-group-del]');
|
||||
if (delGrp) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt(delGrp.dataset.groupDel);
|
||||
const grp = _groups.find(g => g.id === id);
|
||||
if (!confirm('Delete group "' + (grp?.displayName||id) + '"?')) return;
|
||||
_post({ action:'lldap_delete_group', id }, r => { if (r.ok) _loadGroups(); });
|
||||
return;
|
||||
}
|
||||
|
||||
const rmUser = e.target.closest('[data-rm-user]');
|
||||
if (rmUser) {
|
||||
e.stopPropagation();
|
||||
const uid = rmUser.dataset.rmUser;
|
||||
const gid = parseInt(rmUser.dataset.gid);
|
||||
_post({ action:'lldap_remove_from_group', uid, gid }, r => { if (r.ok) { _loadUsers(); _loadGroups(); } });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Access Control ────────────────────────────────────────────────────────────
|
||||
function _loadAcl() {
|
||||
const loading = document.getElementById('vv-au-acl-loading');
|
||||
const tbl = document.getElementById('vv-au-acl-tbl');
|
||||
const empty = document.getElementById('vv-au-acl-empty');
|
||||
loading.style.display = 'block';
|
||||
tbl.style.display = 'none';
|
||||
empty.style.display = 'none';
|
||||
|
||||
_get('authelia_rules', r => {
|
||||
loading.style.display = 'none';
|
||||
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
|
||||
_rules = r.rules || [];
|
||||
_defaultPolicy = r.default_policy || 'deny';
|
||||
|
||||
const sel = document.getElementById('vv-au-ac-defpol');
|
||||
if (sel) sel.value = _defaultPolicy;
|
||||
|
||||
if (!_rules.length) { empty.style.display = 'block'; return; }
|
||||
tbl.style.display = 'table';
|
||||
_renderAcl();
|
||||
});
|
||||
}
|
||||
|
||||
function _renderAcl() {
|
||||
const count = document.getElementById('vv-au-acl-count');
|
||||
count.textContent = _rules.length + ' rule' + (_rules.length !== 1 ? 's' : '');
|
||||
|
||||
const body = document.getElementById('vv-au-acl-body');
|
||||
body.innerHTML = _rules.map((rule, i) => {
|
||||
const domains = _normDomain(rule.domain).join(', ');
|
||||
const subjects = _normSubject(rule.subject).join(', ');
|
||||
const upBtn = i === 0 ? '<span style="width:18px;display:inline-block"></span>' :
|
||||
`<button class="vv-au-icon-btn" data-rule-up="${i}" title="Move up">↑</button>`;
|
||||
const dnBtn = i === _rules.length-1 ? '<span style="width:18px;display:inline-block"></span>' :
|
||||
`<button class="vv-au-icon-btn" data-rule-dn="${i}" title="Move down">↓</button>`;
|
||||
return `<tr>
|
||||
<td class="vv-au-rule-num">${i+1}</td>
|
||||
<td><div class="vv-au-domain">${_esc(domains)}</div></td>
|
||||
<td>${_policyBadge(rule.policy)}</td>
|
||||
<td style="font-size:10px;color:#555">${_esc(subjects||'—')}</td>
|
||||
<td>
|
||||
<div class="vv-au-rule-acts">
|
||||
${upBtn}${dnBtn}
|
||||
<button class="vv-au-icon-btn" data-rule-edit="${i}" title="Edit">✎</button>
|
||||
<button class="vv-au-icon-btn del" data-rule-del="${i}" title="Delete">✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _ruleModal(idx) {
|
||||
const rule = idx !== null ? _rules[idx] : null;
|
||||
const domains = _normDomain(rule?.domain).join(', ');
|
||||
const subjects = _normSubject(rule?.subject).join(', ');
|
||||
const nets = (rule?.networks||[]).join(', ');
|
||||
const resources= (rule?.resources||[]).join(', ');
|
||||
|
||||
const policyOpts = ['bypass','one_factor','two_factor','deny'].map(p =>
|
||||
`<option value="${p}"${(rule?.policy||'two_factor')===p?' selected':''}>${p}</option>`
|
||||
).join('');
|
||||
|
||||
_modal(`<h3>${rule ? 'Edit Rule' : 'Add Rule'}</h3>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Domain</label>
|
||||
<input class="vv-au-input" id="rm-domain" value="${_esc(domains)}" placeholder="*.example.com, example.com">
|
||||
<div class="vv-au-hint">Comma-separated; wildcards supported</div>
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Policy</label>
|
||||
<select class="vv-au-select" id="rm-policy">${policyOpts}</select>
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Subject <span style="color:#2a2a2a">(optional)</span></label>
|
||||
<input class="vv-au-input" id="rm-subject" value="${_esc(subjects)}" placeholder="group:admins, user:john">
|
||||
<div class="vv-au-hint">Comma-separated; prefix with group: or user:</div>
|
||||
</div>
|
||||
<div class="vv-au-adv-toggle" id="rm-adv-toggle">▶ Advanced (networks, resources)</div>
|
||||
<div class="vv-au-adv-section" id="rm-adv">
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Networks <span style="color:#2a2a2a">(optional)</span></label>
|
||||
<input class="vv-au-input" id="rm-networks" value="${_esc(nets)}" placeholder="192.168.1.0/24, 10.0.0.0/8">
|
||||
<div class="vv-au-hint">Comma-separated CIDR ranges</div>
|
||||
</div>
|
||||
<div class="vv-au-field">
|
||||
<label class="vv-au-label">Resources <span style="color:#2a2a2a">(optional)</span></label>
|
||||
<input class="vv-au-input" id="rm-resources" value="${_esc(resources)}" placeholder="^/api, ^/admin">
|
||||
<div class="vv-au-hint">Comma-separated regex patterns</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vv-au-err" id="rm-err"></div>
|
||||
<div class="vv-au-modal-acts">
|
||||
<button class="vv-au-btn" id="rm-cancel">Cancel</button>
|
||||
<button class="vv-au-btn prim" id="rm-save">${rule ? 'Save' : 'Add Rule'}</button>
|
||||
</div>`);
|
||||
|
||||
document.getElementById('rm-adv-toggle').addEventListener('click', () => {
|
||||
const sec = document.getElementById('rm-adv');
|
||||
const tog = document.getElementById('rm-adv-toggle');
|
||||
const open = sec.classList.toggle('open');
|
||||
tog.textContent = (open ? '▼' : '▶') + ' Advanced (networks, resources)';
|
||||
});
|
||||
|
||||
document.getElementById('rm-cancel').onclick = _closeModal;
|
||||
document.getElementById('rm-save').onclick = () => {
|
||||
const domains = document.getElementById('rm-domain').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||||
if (!domains.length) { _showModalErr('rm-err','Domain required'); return; }
|
||||
|
||||
const subjects = document.getElementById('rm-subject').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||||
const networks = document.getElementById('rm-networks').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||||
const resources = document.getElementById('rm-resources').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||||
|
||||
const newRule = {
|
||||
domain: domains.length === 1 ? domains[0] : domains,
|
||||
policy: document.getElementById('rm-policy').value,
|
||||
};
|
||||
if (subjects.length) newRule.subject = subjects.length === 1 ? subjects[0] : subjects;
|
||||
if (networks.length) newRule.networks = networks;
|
||||
if (resources.length) newRule.resources = resources;
|
||||
|
||||
if (idx !== null) _rules[idx] = newRule;
|
||||
else _rules.push(newRule);
|
||||
|
||||
_closeModal();
|
||||
_renderAcl();
|
||||
};
|
||||
}
|
||||
|
||||
// ACL event delegation
|
||||
document.getElementById('vv-au-panel-acl').addEventListener('click', e => {
|
||||
if (e.target.id === 'vv-au-rule-add') { _ruleModal(null); return; }
|
||||
|
||||
if (e.target.id === 'vv-au-ac-save') {
|
||||
const dp = document.getElementById('vv-au-ac-defpol').value;
|
||||
const btn = document.getElementById('vv-au-ac-save');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
_post({ action:'authelia_save', rules: JSON.stringify(_rules), default_policy: dp }, r => {
|
||||
btn.disabled = false; btn.textContent = 'Save & Restart Authelia';
|
||||
if (!r.ok) { alert('Save failed: ' + (r.error||'unknown error')); return; }
|
||||
// brief visual confirmation
|
||||
btn.textContent = 'Saved ✓';
|
||||
setTimeout(() => { btn.textContent = 'Save & Restart Authelia'; }, 2000);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const editBtn = e.target.closest('[data-rule-edit]');
|
||||
if (editBtn) { _ruleModal(parseInt(editBtn.dataset.ruleEdit)); return; }
|
||||
|
||||
const delBtn = e.target.closest('[data-rule-del]');
|
||||
if (delBtn) {
|
||||
const i = parseInt(delBtn.dataset.ruleDel);
|
||||
if (!confirm('Delete this rule?')) return;
|
||||
_rules.splice(i, 1);
|
||||
_renderAcl();
|
||||
return;
|
||||
}
|
||||
|
||||
const upBtn = e.target.closest('[data-rule-up]');
|
||||
if (upBtn) {
|
||||
const i = parseInt(upBtn.dataset.ruleUp);
|
||||
if (i > 0) { [_rules[i-1], _rules[i]] = [_rules[i], _rules[i-1]]; _renderAcl(); }
|
||||
return;
|
||||
}
|
||||
|
||||
const dnBtn = e.target.closest('[data-rule-dn]');
|
||||
if (dnBtn) {
|
||||
const i = parseInt(dnBtn.dataset.ruleDn);
|
||||
if (i < _rules.length-1) { [_rules[i], _rules[i+1]] = [_rules[i+1], _rules[i]]; _renderAcl(); }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Close modal on overlay click ──────────────────────────────────────────────
|
||||
document.getElementById('vv-au-overlay').addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('vv-au-overlay')) _closeModal();
|
||||
});
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||
_loadProxies();
|
||||
|
||||
})();
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
// Diagnostic endpoint — tests the Unraid GraphQL API and returns raw results.
|
||||
// Hit from browser: /plugins/varaverk/api/api_test.php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$hostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$key = $vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '';
|
||||
|
||||
$result = [
|
||||
'host' => $hostId,
|
||||
'key_present' => $key !== '',
|
||||
'key_prefix' => $key ? substr($key, 0, 8) . '...' : null,
|
||||
'curl_available' => function_exists('curl_init'),
|
||||
'allow_url_fopen'=> (bool)ini_get('allow_url_fopen'),
|
||||
'debug_log' => null,
|
||||
'probe' => null,
|
||||
'probe_raw' => null,
|
||||
];
|
||||
|
||||
// Show last debug log if present
|
||||
$debugFile = '/tmp/vv_api_debug.json';
|
||||
if (file_exists($debugFile)) {
|
||||
$result['debug_log'] = json_decode(file_get_contents($debugFile), true);
|
||||
}
|
||||
|
||||
// Run a minimal probe query
|
||||
if ($key) {
|
||||
$url = 'http://localhost/graphql';
|
||||
$body = json_encode(['query' => '{ info { os { hostname } } }']);
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
} else {
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
|
||||
'content' => $body,
|
||||
'timeout' => 5,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$raw = @file_get_contents($url, false, $ctx);
|
||||
$httpCode = $raw !== false ? 200 : 0;
|
||||
$curlErr = '';
|
||||
}
|
||||
|
||||
$result['probe'] = [
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'curl_err' => $curlErr ?: null,
|
||||
'decoded' => json_decode((string)$raw, true),
|
||||
];
|
||||
$result['probe_raw'] = substr((string)$raw, 0, 1000);
|
||||
|
||||
// ── Schema introspection — discover actual field names ────────────────────────
|
||||
if ($key) {
|
||||
// ArrayParity and ArrayCache were separate named types in 7.2.5 — removed in 7.3 (now same as ArrayDisk)
|
||||
$types = ['InfoOs','InfoCpu','InfoMemory','ArrayDisk','VmDomain'];
|
||||
$introspectGql = '{ ' . implode(' ', array_map(fn($t) =>
|
||||
"{$t}: __type(name: \"{$t}\") { fields { name type { name kind ofType { name kind } } } }",
|
||||
$types
|
||||
)) . ' }';
|
||||
|
||||
$body2 = json_encode(['query' => $introspectGql]);
|
||||
$ch2 = curl_init('http://localhost/graphql');
|
||||
curl_setopt_array($ch2, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => $body2,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$raw2 = curl_exec($ch2);
|
||||
curl_close($ch2);
|
||||
$result['schema'] = json_decode((string)$raw2, true)['data'] ?? null;
|
||||
|
||||
// Pool drive names — what does the API actually return for cache/pool drives?
|
||||
$ch_pools = curl_init('http://localhost/graphql');
|
||||
curl_setopt_array($ch_pools, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => json_encode(['query' => '{ array { caches { name device type status fsType } } }']),
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$result['pool_drives'] = json_decode((string)curl_exec($ch_pools), true)['data'] ?? null;
|
||||
curl_close($ch_pools);
|
||||
|
||||
// Round 2: introspect CpuUtilization and MemoryUtilization field names
|
||||
$types2 = ['CpuUtilization','MemoryUtilization','TemperatureMetrics'];
|
||||
$gql2 = '{ ' . implode(' ', array_map(fn($t) =>
|
||||
"{$t}: __type(name: \"{$t}\") { kind fields { name type { name kind ofType { name kind } } } }",
|
||||
$types2
|
||||
)) . ' }';
|
||||
$ch3 = curl_init('http://localhost/graphql');
|
||||
curl_setopt_array($ch3, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => json_encode(['query' => $gql2]),
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$result['schema2'] = json_decode((string)curl_exec($ch3), true)['data'] ?? null;
|
||||
curl_close($ch3);
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($result, JSON_PRETTY_PRINT);
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
// Diagnostic endpoint — tests the Unraid GraphQL API and returns raw results.
|
||||
// Hit from browser: /plugins/varaverk/api/api_test.php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$hostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$key = $vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '';
|
||||
|
||||
$result = [
|
||||
'host' => $hostId,
|
||||
'key_present' => $key !== '',
|
||||
'key_prefix' => $key ? substr($key, 0, 8) . '...' : null,
|
||||
'curl_available' => function_exists('curl_init'),
|
||||
'allow_url_fopen'=> (bool)ini_get('allow_url_fopen'),
|
||||
'debug_log' => null,
|
||||
'probe' => null,
|
||||
'probe_raw' => null,
|
||||
];
|
||||
|
||||
// Show last debug log if present
|
||||
$debugFile = VV_CACHE_DIR . '/vv_api_debug.json';
|
||||
if (file_exists($debugFile)) {
|
||||
$result['debug_log'] = json_decode(file_get_contents($debugFile), true);
|
||||
}
|
||||
|
||||
// Run a minimal probe query
|
||||
if ($key) {
|
||||
$url = 'http://localhost/graphql';
|
||||
$body = json_encode(['query' => '{ info { os { hostname } } }']);
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
} else {
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
|
||||
'content' => $body,
|
||||
'timeout' => 5,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$raw = @file_get_contents($url, false, $ctx);
|
||||
$httpCode = $raw !== false ? 200 : 0;
|
||||
$curlErr = '';
|
||||
}
|
||||
|
||||
$result['probe'] = [
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'curl_err' => $curlErr ?: null,
|
||||
'decoded' => json_decode((string)$raw, true),
|
||||
];
|
||||
$result['probe_raw'] = substr((string)$raw, 0, 1000);
|
||||
|
||||
// ── Schema introspection — discover actual field names ────────────────────────
|
||||
if ($key) {
|
||||
// ArrayParity and ArrayCache were separate named types in 7.2.5 — removed in 7.3 (now same as ArrayDisk)
|
||||
$types = ['InfoOs','InfoCpu','InfoMemory','ArrayDisk','VmDomain'];
|
||||
$introspectGql = '{ ' . implode(' ', array_map(fn($t) =>
|
||||
"{$t}: __type(name: \"{$t}\") { fields { name type { name kind ofType { name kind } } } }",
|
||||
$types
|
||||
)) . ' }';
|
||||
|
||||
$body2 = json_encode(['query' => $introspectGql]);
|
||||
$ch2 = curl_init('http://localhost/graphql');
|
||||
curl_setopt_array($ch2, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => $body2,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$raw2 = curl_exec($ch2);
|
||||
curl_close($ch2);
|
||||
$result['schema'] = json_decode((string)$raw2, true)['data'] ?? null;
|
||||
|
||||
// Pool drive names — what does the API actually return for cache/pool drives?
|
||||
$ch_pools = curl_init('http://localhost/graphql');
|
||||
curl_setopt_array($ch_pools, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => json_encode(['query' => '{ array { caches { name device type status fsType } } }']),
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$result['pool_drives'] = json_decode((string)curl_exec($ch_pools), true)['data'] ?? null;
|
||||
curl_close($ch_pools);
|
||||
|
||||
// Round 2: introspect CpuUtilization and MemoryUtilization field names
|
||||
$types2 = ['CpuUtilization','MemoryUtilization','TemperatureMetrics'];
|
||||
$gql2 = '{ ' . implode(' ', array_map(fn($t) =>
|
||||
"{$t}: __type(name: \"{$t}\") { kind fields { name type { name kind ofType { name kind } } } }",
|
||||
$types2
|
||||
)) . ' }';
|
||||
$ch3 = curl_init('http://localhost/graphql');
|
||||
curl_setopt_array($ch3, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => json_encode(['query' => $gql2]),
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$result['schema2'] = json_decode((string)curl_exec($ch3), true)['data'] ?? null;
|
||||
curl_close($ch3);
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($result, JSON_PRETTY_PRINT);
|
||||
@@ -0,0 +1,644 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ========================== HOST2 CONFIGURATION — unRAID-Jayred365 ===========================
|
||||
# ==============================================================================================
|
||||
# HOST2-specific variables — credentials, container names, share paths, failover lists.
|
||||
# Sourced after master.conf — values here extend shared profile arrays and add HOST2-specific
|
||||
# identity, credentials, and container configuration.
|
||||
#
|
||||
# Sparse checkout (git) ensures HOST1 never receives this file.
|
||||
# HOST1 never sees HOST2 credentials — clean separation at the file level.
|
||||
#
|
||||
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
|
||||
# DO NOT put HOST1 variables here — they belong in host1.conf.
|
||||
#
|
||||
# ── STATUS ────────────────────────────────────────────────────────────────────────────────────
|
||||
# HOST2 is currently being rebuilt — most sections scaffolded, fill in when back online.
|
||||
# When ready: set FALLBACK_ENABLED=true and DAILY_RSYNC_ENABLED=true in master.conf.
|
||||
#
|
||||
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
|
||||
# IDENTITY hostname, SSH key
|
||||
# EMBY container name, URL, API key
|
||||
# NOTIFICATIONS Discord webhook
|
||||
# PARTNERSHIP auth containers, backup paths
|
||||
#
|
||||
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
|
||||
# DAILY SYNC SHARES media shares HOST2 owns and pushes to HOST1
|
||||
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
|
||||
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
|
||||
# BACKUP VERIFY shares for checksum verification against remote
|
||||
# HOST2 RSYNC PROFILE host2-appdata profile for HOST2-specific appdata syncs
|
||||
#
|
||||
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
|
||||
# DOCKER DAILY RESTART containers restarted daily
|
||||
# DOCKER WEEKLY RESTART containers restarted weekly
|
||||
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
|
||||
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
|
||||
#
|
||||
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
|
||||
# DDNS DDNS containers managed by HOST2
|
||||
# INTERNET LOSS containers stopped when internet is lost
|
||||
# FALLBACK TIERS what HOST2 runs for HOST1 per tier
|
||||
# TIER DELAYS how long HOST2 must be down before each tier activates on HOST1
|
||||
# RSYNC WRITEBACK HOST2 appdata synced back on handback
|
||||
#
|
||||
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
|
||||
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
|
||||
# MEDIA CLEANER folder lists for media_cleaner.sh
|
||||
#
|
||||
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
|
||||
# CERTIFICATE MONITOR domains checked for SSL expiry
|
||||
# SMART HEALTH drives to skip in SMART monitoring
|
||||
# ZFS REPORT pools to exclude from ZFS health report
|
||||
#
|
||||
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
|
||||
# TRANSCODES ramdisk size, thresholds, SSD path, server array
|
||||
#
|
||||
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
|
||||
# SONARR URL, API key, path map
|
||||
# RADARR URL, API key, path map
|
||||
# ARR RECOVERY per-arr recovery toggles (no Lidarr on HOST2)
|
||||
#
|
||||
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
|
||||
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
|
||||
#
|
||||
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
|
||||
# RESOURCE MANAGER containers paused/stopped under memory pressure
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
# ==============================================================================================
|
||||
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Identity ━━━
|
||||
# HOST2 hostname lives in master.conf (not a credential — safe for all servers).
|
||||
# SSH key used for all server-to-server operations — rsync, failover container commands.
|
||||
# Must be in /root/.ssh/ and authorised in HOST1's /root/.ssh/authorized_keys.
|
||||
HOST2_SSH_KEY="/root/.ssh/Jayred365-rsync-key"
|
||||
HOST2_OWNER="jayred365"
|
||||
HOST2_OWNER_EMAIL="" # fill in when HOST2 is back online
|
||||
|
||||
# ━━━ Unraid API ━━━
|
||||
# Generate in Unraid: Settings → Management Access → API Keys → + New Key
|
||||
HOST2_UNRAID_API_KEY="2bdf5119d61eefa3023434748bd1c171bd23dc0b2ebc8586e24abe07df986acc"
|
||||
|
||||
# ━━━ Emby ━━━
|
||||
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
|
||||
# weekly_sync_maintenance.sh, and HOST2_TRANSCODE_SERVERS below.
|
||||
# API key: Emby Dashboard → API Keys → + New Key
|
||||
HOST2_EMBY_CONTAINER="Emby-Jayred365"
|
||||
HOST2_EMBY_URL="http://localhost:8096"
|
||||
HOST2_EMBY_API_KEY="your-host2-emby-api-key"
|
||||
|
||||
# ━━━ Jellyfin ━━━
|
||||
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
|
||||
HOST2_JELLYFIN_CONTAINER="Jellyfin"
|
||||
HOST2_JELLYFIN_URL="http://localhost:8095"
|
||||
HOST2_JELLYFIN_API_KEY="956d0168987f4e4680626653abb080f0"
|
||||
|
||||
# ━━━ Notifications ━━━
|
||||
# Discord webhook — leave blank to disable.
|
||||
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
|
||||
HOST2_DISCORD_WEBHOOK=""
|
||||
|
||||
# ━━━ Partnership ━━━
|
||||
# HOST2 is the mirror — HOST1 is always the owner unless --transfer has been run.
|
||||
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
|
||||
|
||||
# Auth containers reconfigured on onboard/offboard.
|
||||
# Format: "ContainerName|WebUIPort"
|
||||
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
|
||||
# On offboard → WebUI pointed back at localhost
|
||||
HOST2_PARTNERSHIP_AUTH_WEBUIS=(
|
||||
# fill in when HOST2 is back online
|
||||
# "NginxProxyManager|81"
|
||||
)
|
||||
|
||||
# Containers to stop on this server before the owner deploys the auth stack during onboard.
|
||||
# List whatever auth/proxy containers are currently running here.
|
||||
HOST2_PARTNERSHIP_REPLACE_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
"Mariadb-Authelia"
|
||||
"Mariadb-Authelia-Secondary"
|
||||
"Redis-Authelia"
|
||||
"Redis-Authelia-Secondary"
|
||||
"Lldap"
|
||||
)
|
||||
|
||||
# Arr containers to stop on this server before the owner deploys the arr stack during onboard.
|
||||
HOST2_PARTNERSHIP_ARR_REPLACE_CONTAINERS=(
|
||||
# "Sonarr"
|
||||
# "Radarr"
|
||||
# "Lidarr"
|
||||
# "Prowlarr"
|
||||
# "Bazarr"
|
||||
)
|
||||
|
||||
# Paths HOST1 should collect during the grace window after offboard.
|
||||
# Notified on offboard — no auto-deletion, HOST1 must collect manually within PARTNERSHIP_GRACE_HOURS.
|
||||
HOST2_PARTNERSHIP_MIRROR_BACKUPS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# Containers parked on this server when partnership is active.
|
||||
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
|
||||
HOST2_PARTNERSHIP_OWN_CONTAINERS=(
|
||||
# "Emby"
|
||||
# "NginxProxyManager"
|
||||
)
|
||||
|
||||
# This server's desired Emby admin account on the shared Emby instance.
|
||||
# Set these — owner reads them during --onboard to create the account.
|
||||
HOST2_PARTNERSHIP_EMBY_ADMIN_USER="" # desired Emby username
|
||||
HOST2_PARTNERSHIP_EMBY_ADMIN_PASS="" # desired Emby password
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Daily Sync Shares ━━━
|
||||
# Shares HOST2 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
|
||||
# Mesh model: every node pushes every media share — no ownership, no mirrors.
|
||||
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
|
||||
# arr_cleanup removes true orphans based on local arr state.
|
||||
# Any node can download content to any share — it propagates to all nodes on the next cycle.
|
||||
# Nextcloud excluded — personal data, not arr-managed, synced HOST1→HOST2 only as offsite backup.
|
||||
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
|
||||
# For shares needing container stops or custom options — add a profile in master.conf.
|
||||
HOST2_DAILY_SYNC_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Books
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Sports
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/Anime_Movies-Old
|
||||
)
|
||||
|
||||
# Personal encrypted shares — synced for offsite backup, independent of media shares.
|
||||
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
|
||||
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
|
||||
HOST2_PERSONAL_SHARES=(
|
||||
# /mnt/user/HOST2-Personal # uncomment after creating encrypted dataset
|
||||
)
|
||||
|
||||
# ━━━ Weekly Sync Shares ━━━
|
||||
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
|
||||
# Containers stopped both sides before sync — full clean state guaranteed.
|
||||
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
|
||||
HOST2_WEEKLY_SYNC_SHARES=(
|
||||
# fill in when HOST2 is back online
|
||||
# "/mnt/user/Media_Server/Emby"
|
||||
# "/mnt/user/appdata-Fallback/Critical-Data"
|
||||
)
|
||||
|
||||
# ━━━ Intermediate Sync Shares ━━━
|
||||
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
|
||||
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
|
||||
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
|
||||
HOST2_INTERMEDIATE_SYNC_SHARES=(
|
||||
# fill in when HOST2 is back online
|
||||
# Example: "/mnt/user/Emby_Metadata"
|
||||
)
|
||||
|
||||
# ━━━ Critical Sync Shares ━━━
|
||||
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
|
||||
# Format: "/path/to/share" or "/path/to/share|profile-name"
|
||||
HOST2_CRITICAL_SYNC_SHARES=(
|
||||
# fill in when HOST2 is back online
|
||||
# "/mnt/user/appdata-Fallback/Critical-Data|critical-fallback"
|
||||
# "/mnt/user/Media_Server/Emby|emby-fallback"
|
||||
)
|
||||
|
||||
# ━━━ Backup Verify ━━━
|
||||
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
|
||||
# Leave empty to use HOST2_DAILY_SYNC_SHARES automatically.
|
||||
# Sample size and minimum file size defined in master.conf.
|
||||
HOST2_BACKUP_VERIFY_SHARES=(
|
||||
# leave empty to use HOST2_DAILY_SYNC_SHARES automatically
|
||||
)
|
||||
|
||||
# ━━━ HOST2 Rsync Profile — host2-appdata ━━━
|
||||
# HOST2-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
|
||||
# Use for appdata unique to HOST2.
|
||||
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
|
||||
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST2-Appdata --profile=host2-appdata
|
||||
PROFILE_RSYNC_OPTS[host2-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host2-appdata]:-8000}"
|
||||
PROFILE_BW_LIMIT[host2-appdata]=8000
|
||||
PROFILE_RETRY_COUNT[host2-appdata]=3
|
||||
PROFILE_SLEEP[host2-appdata]=300
|
||||
PROFILE_CRITICAL_CONTAINER_NAMES[host2-appdata]="" # fill in when HOST2 is back online
|
||||
PROFILE_DELAYED_CONTAINERS[host2-appdata]=""
|
||||
PROFILE_CONTAINER_DELAY[host2-appdata]=5
|
||||
PROFILE_EXCLUDE_DIRS[host2-appdata]="logs *.tmp"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Docker Daily Restart ━━━
|
||||
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
|
||||
# Fill in when HOST2 is back online — add containers that degrade without daily restart.
|
||||
HOST2_DAILY_RESTART_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
# add HOST2 daily restart containers here
|
||||
)
|
||||
|
||||
# ━━━ Docker Weekly Restart ━━━
|
||||
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
|
||||
# Containers already stopped for weekly sync — restart adds zero extra downtime.
|
||||
HOST2_WEEKLY_RESTART_CONTAINERS=(
|
||||
# add HOST2 weekly restart containers here
|
||||
)
|
||||
|
||||
# ━━━ Docker Watchdog ━━━
|
||||
# Per-HOST2 container configuration for docker_watchdog.sh.
|
||||
# Shared thresholds and toggles live in master.conf.
|
||||
|
||||
# Memory hard limits in MB — immediate restart if exceeded.
|
||||
# Set at "container is clearly broken" not "container is busy".
|
||||
# 20GB=20480 16GB=16384 12GB=12288 10GB=10240 8GB=8192 4GB=4096 2GB=2048 1GB=1024
|
||||
declare -A HOST2_WATCHDOG_CONTAINERS=(
|
||||
["Emby"]=16384 # fill in correct limit when HOST2 is back online
|
||||
)
|
||||
|
||||
# HTTP health check URLs — checked every cycle, strike system before restart.
|
||||
# Only add containers with a meaningful web interface to check.
|
||||
declare -A HOST2_WATCHDOG_CONTAINER_URLS=(
|
||||
["Emby"]="http://localhost:8096"
|
||||
)
|
||||
|
||||
# Required containers — must always be running on HOST2.
|
||||
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
|
||||
# Listed in dependency order — dependencies before dependents.
|
||||
HOST2_WATCHDOG_REQUIRED_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
# add HOST2 required containers here when back online
|
||||
)
|
||||
|
||||
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
|
||||
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
|
||||
HOST2_WATCHDOG_SCAN_IGNORE=(
|
||||
# add HOST2 scan ignore containers here when back online
|
||||
)
|
||||
|
||||
# Dependency ordering — skip restarting a container if its dependency is also down.
|
||||
# Prevents watchdog from restarting dependent services before their dependencies are up.
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime.
|
||||
declare -A HOST2_WATCHDOG_DEPENDENCIES=(
|
||||
# add HOST2 dependencies here when containers are defined
|
||||
# ["Authelia"]="Mariadb-Authelia Redis-Authelia"
|
||||
)
|
||||
|
||||
# Per-container appdata growth suppress ceilings in MB.
|
||||
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
|
||||
# Use when a container legitimately has large stable data and you want to suppress false-positive
|
||||
# growth alerts. Add entries here only when a container triggers warnings it shouldn't.
|
||||
declare -A HOST2_WATCHDOG_APPDATA_SIZES=(
|
||||
# add HOST2 suppress entries here only as needed
|
||||
)
|
||||
|
||||
# ━━━ Docker Network Connect ━━━
|
||||
# Containers connected to custom networks at array start by docker_network_connect.sh.
|
||||
# Networks created if they don't exist — idempotent, safe to re-run.
|
||||
HOST2_NETWORK_CONNECT_CONTAINERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
HOST2_NETWORK_CONNECT_NETWORKS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ DDNS ━━━
|
||||
# DDNS containers HOST2 manages — started/stopped by fallback.sh per DDNS absolute rules:
|
||||
# Internet loss → stop immediately
|
||||
# Failover → HOST1 starts HOST2's DDNS as Tier 1 (before any other containers)
|
||||
# Handback → stop HOST2's DDNS on HOST1 → rsync → start containers → start local DDNS last
|
||||
HOST2_DDNS_CONTAINERS=(
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ Internet Loss ━━━
|
||||
# Containers stopped immediately on HOST2 when internet connection is lost.
|
||||
# Prevents external-facing services from operating without connectivity.
|
||||
FALLBACK_HOST2_STOP_ON_NO_NET=(
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ Fallback Tiers — HOST2 Runs for HOST1 ━━━
|
||||
# Containers HOST2 starts when HOST1 goes down.
|
||||
# Tier 1 is always immediate — vital services cannot wait.
|
||||
# Higher tiers activate after HOST1_TIER*_DELAY minutes (set in host1.conf).
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER1=(
|
||||
"Gmer4Lfe.com"
|
||||
"Gitea" # source of truth — must be reachable even when HOST1 auth stack is down
|
||||
"Emby"
|
||||
"VaultWarden-Gmer4Lfe"
|
||||
"Dispatcharr"
|
||||
"Dispatcharr-Basic"
|
||||
"Dispatcharr-Iptv-Users"
|
||||
"ErsatzTV-Emby"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER2=(
|
||||
"Postgres-NextCloud"
|
||||
"NextCloud"
|
||||
"PostgreSQL_Immich"
|
||||
"Immich-Gmer4Lfe"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER3=(
|
||||
"Gitea"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER4=(
|
||||
"Sonarr"
|
||||
"Radarr"
|
||||
"Lidarr"
|
||||
"Readarr"
|
||||
"Prowlarr"
|
||||
"Bazarr"
|
||||
"SABnzbd-Gmer4Lfe"
|
||||
"Qbittorrent-Gmer4Lfe"
|
||||
"LidaTube"
|
||||
"Pinchflat"
|
||||
"ChannelTube"
|
||||
)
|
||||
|
||||
# ━━━ Tier Delays — HOST2's Containers on HOST1 ━━━
|
||||
# How long HOST2 must be down before each tier activates on HOST1 — in minutes.
|
||||
# Tier 1 is always immediate — no delay var needed.
|
||||
HOST2_TIER2_DELAY=240 # 4 hours — productivity services
|
||||
HOST2_TIER3_DELAY=720 # 12 hours — secondary services
|
||||
HOST2_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
|
||||
|
||||
# ━━━ Rsync Writeback — HOST2 Appdata Back on Handback ━━━
|
||||
# Syncs HOST2 appdata BACK to HOST2 when it comes back online after a failover.
|
||||
# Containers stopped before writeback — clean source, no competing writes.
|
||||
#
|
||||
# HOST2_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
|
||||
# is more reliable than dirty sync data for brief outages.
|
||||
HOST2_TIER1_WRITEBACK_DELAY=60 # skip writeback if outage under 1hr
|
||||
|
||||
# Tier 4 automatically syncs HOST2_DAILY_SYNC_SHARES — only list paths NOT in that array.
|
||||
FALLBACK_HOST2_WRITEBACK_TIER1=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_WRITEBACK_TIER2=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Important"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_WRITEBACK_TIER3=(
|
||||
# "location-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_WRITEBACK_TIER4=(
|
||||
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Media Permissions ━━━
|
||||
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
|
||||
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
|
||||
HOST2_MEDIA_PERMISSION_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
# ━━━ Media Cleaner ━━━
|
||||
# Folder lists for media_cleaner.sh — two profiles: anime and media.
|
||||
# File patterns shared across all servers — defined in master.conf.
|
||||
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
|
||||
HOST2_ANIME_CLEAN_FOLDERS=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
HOST2_MEDIA_CLEAN_FOLDERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Certificate Monitor ━━━
|
||||
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
|
||||
# Checks the actual certificate served, not what NPM thinks it has.
|
||||
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
|
||||
HOST2_CERT_MONITOR_DOMAINS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ━━━ SMART Health ━━━
|
||||
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
|
||||
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
|
||||
HOST2_SMART_IGNORE_DRIVES=(
|
||||
"sda" # boot USB — SMART not meaningful on flash drives
|
||||
)
|
||||
|
||||
# ━━━ ZFS Report ━━━
|
||||
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
|
||||
# Pool health thresholds defined in master.conf.
|
||||
HOST2_ZFS_REPORT_IGNORE_POOLS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
|
||||
# Adjust HOST2_RAMDISK_WARN_GB and HOST2_RAMDISK_LOW_GB together if this changes.
|
||||
HOST2_RAMDISK_SIZE="8G"
|
||||
|
||||
# Usage thresholds — coupled to HOST2_RAMDISK_SIZE, adjust all three together if size changes.
|
||||
# Hysteresis gap (6.8 - 5.5 = 1.3GB) prevents flip-flop between ramdisk and SSD.
|
||||
HOST2_RAMDISK_WARN_GB=6.8 # flip to SSD when ramdisk usage reaches this
|
||||
HOST2_RAMDISK_LOW_GB=5.5 # flip back to ramdisk when usage drops to this
|
||||
|
||||
# SSD fallback path — where transcodes land when ramdisk exceeds HOST2_RAMDISK_WARN_GB.
|
||||
# Must be on cache pool — array disks too slow for active transcode writes.
|
||||
HOST2_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
|
||||
|
||||
# Media servers sharing the ramdisk transcode space on HOST2.
|
||||
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
|
||||
# Entries with placeholder API keys are skipped automatically.
|
||||
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
|
||||
HOST2_TRANSCODE_SERVERS=(
|
||||
"${HOST2_EMBY_CONTAINER}|${HOST2_EMBY_URL}|${HOST2_EMBY_API_KEY}|emby"
|
||||
"${HOST2_JELLYFIN_CONTAINER}|${HOST2_JELLYFIN_URL}|${HOST2_JELLYFIN_API_KEY}|jellyfin"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
|
||||
# detect_hosts() selects HOST2 vars when running on HOST2.
|
||||
# Lidarr does not run on HOST2 — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
|
||||
#
|
||||
# PATH MAPS — container path → host path translation.
|
||||
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
|
||||
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
|
||||
HOST2_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
|
||||
HOST2_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
|
||||
|
||||
# ━━━ Sonarr ━━━
|
||||
HOST2_SONARR_URL="http://localhost:8989"
|
||||
HOST2_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
|
||||
HOST2_SONARR_TV_ROOT="/mnt/user/Anime_Shows"
|
||||
|
||||
declare -A HOST2_SONARR_PATH_MAP=(
|
||||
# fill in when HOST2 is back online
|
||||
# ["/tv"]="/mnt/user/Anime_Shows"
|
||||
)
|
||||
|
||||
# ━━━ Radarr ━━━
|
||||
HOST2_RADARR_URL="http://localhost:7878"
|
||||
HOST2_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
|
||||
HOST2_RADARR_MOVIES_ROOT="/mnt/user/Anime_Movies"
|
||||
|
||||
declare -A HOST2_RADARR_PATH_MAP=(
|
||||
# fill in when HOST2 is back online
|
||||
# ["/anime-movies"]="/mnt/user/Anime_Movies"
|
||||
)
|
||||
|
||||
# ━━━ Arr Recovery Toggles ━━━
|
||||
# false = skip that arr on this host — exits cleanly without error
|
||||
HOST2_SONARR_RECOVERY=true
|
||||
HOST2_RADARR_RECOVERY=true
|
||||
# HOST2_LIDARR_RECOVERY not set — Lidarr does not run on HOST2
|
||||
|
||||
# ==============================================================================================
|
||||
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Per-host check toggles and NIC config for system_watchdog.sh.
|
||||
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
|
||||
# HOST2: i5 10th gen 64GB — being rebuilt, lighter workload, no ZFS cache pools.
|
||||
#
|
||||
# Conservative defaults during rebuild — re-enable checks as HOST2 stabilises.
|
||||
# Three-tier response — all critical checks enabled regardless of rebuild state:
|
||||
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
|
||||
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
|
||||
# Tier 3 (standard strike system): selectively disabled during rebuild
|
||||
#
|
||||
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
|
||||
|
||||
# ━━━ Primary NIC ━━━
|
||||
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
|
||||
# Common values: eth0, bond0, br0, eno1
|
||||
HOST2_SYS_WATCHDOG_NIC="eth0"
|
||||
|
||||
# ━━━ Tier 1 — Critical Checks ━━━
|
||||
# All critical checks always enabled — these protect against acute failure regardless of
|
||||
# rebuild state. Disabling any is not recommended.
|
||||
|
||||
# Docker daemon unresponsive → try restart, reboot if restart fails.
|
||||
HOST2_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
|
||||
|
||||
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_ROOTFS=true
|
||||
|
||||
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
|
||||
|
||||
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_FD=true
|
||||
|
||||
# /boot read-only detected → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_BOOT=true
|
||||
|
||||
# ━━━ Tier 2 — Urgent OOM Check ━━━
|
||||
# Both must be enabled for Tier 2 bypass to function.
|
||||
|
||||
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
|
||||
HOST2_SYS_WATCHDOG_CHECK_OOM=true
|
||||
|
||||
# Free RAM check — 64GB RAM on HOST2, tiers adjusted relative to HOST1.
|
||||
# Update master.conf SYS_WATCHDOG_MEM_* thresholds if HOST2 needs different values.
|
||||
# Currently inheriting shared master.conf values — may want lower thresholds on 64GB.
|
||||
HOST2_SYS_WATCHDOG_CHECK_RAM=true
|
||||
|
||||
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
|
||||
# Several checks disabled during rebuild — enable progressively as HOST2 stabilises.
|
||||
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action.
|
||||
|
||||
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
|
||||
HOST2_SYS_WATCHDOG_CHECK_LOG=true
|
||||
|
||||
# ZFS ARC memory check.
|
||||
# DISABLED — HOST2 has no ZFS cache pools. Enable if ZFS pools are added later.
|
||||
HOST2_SYS_WATCHDOG_CHECK_ARC=false
|
||||
|
||||
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
|
||||
HOST2_SYS_WATCHDOG_CHECK_CPU_TEMP=true
|
||||
|
||||
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
|
||||
# DISABLED — rebuild operations cause legitimate load spikes. Enable after rebuild.
|
||||
HOST2_SYS_WATCHDOG_CHECK_LOAD=false
|
||||
|
||||
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
|
||||
HOST2_SYS_WATCHDOG_CHECK_ZOMBIES=true
|
||||
|
||||
# docker_watchdog.sh persistent skip list check.
|
||||
# DISABLED during rebuild — skip list may be unreliable mid-rebuild, avoid false reboots.
|
||||
# Enable once HOST2 is fully operational and docker_watchdog.sh is running stably.
|
||||
HOST2_SYS_WATCHDOG_CHECK_CONTAINERS=false
|
||||
|
||||
# /tmp filesystem usage with auto-clear attempt.
|
||||
HOST2_SYS_WATCHDOG_CHECK_TMP=true
|
||||
|
||||
# Array disk error count delta in /proc/mdstat.
|
||||
HOST2_SYS_WATCHDOG_CHECK_MDSTAT=true
|
||||
|
||||
# Primary NIC operstate — uses HOST2_SYS_WATCHDOG_NIC above.
|
||||
HOST2_SYS_WATCHDOG_CHECK_NETWORK=true
|
||||
|
||||
# sshd running check — restart attempt before escalating.
|
||||
HOST2_SYS_WATCHDOG_CHECK_SSHD=true
|
||||
|
||||
# Runaway process detection.
|
||||
# DISABLED — rebuild workloads may legitimately peg CPU. Enable after rebuild.
|
||||
HOST2_SYS_WATCHDOG_CHECK_RUNAWAY=false
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
|
||||
|
||||
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
|
||||
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
|
||||
HOST2_RW_PAUSE_CONTAINERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
|
||||
# Full stop — these are optional/heavy services that free significant RAM when stopped.
|
||||
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
|
||||
HOST2_RW_STOP_CONTAINERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ──────────────────────── End Of HOST2 Variables ──────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
@@ -0,0 +1,644 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ========================== HOST2 CONFIGURATION — unRAID-Jayred365 ===========================
|
||||
# ==============================================================================================
|
||||
# HOST2-specific variables — credentials, container names, share paths, failover lists.
|
||||
# Sourced after master.conf — values here extend shared profile arrays and add HOST2-specific
|
||||
# identity, credentials, and container configuration.
|
||||
#
|
||||
# Sparse checkout (git) ensures HOST1 never receives this file.
|
||||
# HOST1 never sees HOST2 credentials — clean separation at the file level.
|
||||
#
|
||||
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
|
||||
# DO NOT put HOST1 variables here — they belong in host1.conf.
|
||||
#
|
||||
# ── STATUS ────────────────────────────────────────────────────────────────────────────────────
|
||||
# HOST2 is currently being rebuilt — most sections scaffolded, fill in when back online.
|
||||
# When ready: set FALLBACK_ENABLED=true and DAILY_RSYNC_ENABLED=true in master.conf.
|
||||
#
|
||||
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
|
||||
# IDENTITY hostname, SSH key
|
||||
# EMBY container name, URL, API key
|
||||
# NOTIFICATIONS Discord webhook
|
||||
# PARTNERSHIP auth containers, backup paths
|
||||
#
|
||||
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
|
||||
# DAILY SYNC SHARES media shares HOST2 owns and pushes to HOST1
|
||||
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
|
||||
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
|
||||
# BACKUP VERIFY shares for checksum verification against remote
|
||||
# HOST2 RSYNC PROFILE host2-appdata profile for HOST2-specific appdata syncs
|
||||
#
|
||||
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
|
||||
# DOCKER DAILY RESTART containers restarted daily
|
||||
# DOCKER WEEKLY RESTART containers restarted weekly
|
||||
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
|
||||
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
|
||||
#
|
||||
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
|
||||
# DDNS DDNS containers managed by HOST2
|
||||
# INTERNET LOSS containers stopped when internet is lost
|
||||
# FALLBACK TIERS what HOST2 runs for HOST1 per tier
|
||||
# TIER DELAYS how long HOST2 must be down before each tier activates on HOST1
|
||||
# RSYNC WRITEBACK HOST2 appdata synced back on handback
|
||||
#
|
||||
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
|
||||
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
|
||||
# MEDIA CLEANER folder lists for media_cleaner.sh
|
||||
#
|
||||
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
|
||||
# CERTIFICATE MONITOR domains checked for SSL expiry
|
||||
# SMART HEALTH drives to skip in SMART monitoring
|
||||
# ZFS REPORT pools to exclude from ZFS health report
|
||||
#
|
||||
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
|
||||
# TRANSCODES ramdisk size, thresholds, SSD path, server array
|
||||
#
|
||||
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
|
||||
# SONARR URL, API key, path map
|
||||
# RADARR URL, API key, path map
|
||||
# ARR RECOVERY per-arr recovery toggles (no Lidarr on HOST2)
|
||||
#
|
||||
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
|
||||
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
|
||||
#
|
||||
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
|
||||
# RESOURCE MANAGER containers paused/stopped under memory pressure
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
# ==============================================================================================
|
||||
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Identity ━━━
|
||||
# HOST2 hostname lives in master.conf (not a credential — safe for all servers).
|
||||
# SSH key used for all server-to-server operations — rsync, failover container commands.
|
||||
# Must be in /root/.ssh/ and authorised in HOST1's /root/.ssh/authorized_keys.
|
||||
HOST2_SSH_KEY="/root/.ssh/Jayred365-rsync-key"
|
||||
HOST2_OWNER="jayred365"
|
||||
HOST2_OWNER_EMAIL="" # fill in when HOST2 is back online
|
||||
|
||||
# ━━━ Unraid API ━━━
|
||||
# Generate in Unraid: Settings → Management Access → API Keys → + New Key
|
||||
HOST2_UNRAID_API_KEY="2bdf5119d61eefa3023434748bd1c171bd23dc0b2ebc8586e24abe07df986acc"
|
||||
|
||||
# ━━━ Emby ━━━
|
||||
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
|
||||
# weekly_sync_maintenance.sh, and HOST2_TRANSCODE_SERVERS below.
|
||||
# API key: Emby Dashboard → API Keys → + New Key
|
||||
HOST2_EMBY_CONTAINER="Emby-Jayred365"
|
||||
HOST2_EMBY_URL="http://localhost:8096"
|
||||
HOST2_EMBY_API_KEY="your-host2-emby-api-key"
|
||||
|
||||
# ━━━ Jellyfin ━━━
|
||||
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
|
||||
HOST2_JELLYFIN_CONTAINER="Jellyfin"
|
||||
HOST2_JELLYFIN_URL="http://localhost:8095"
|
||||
HOST2_JELLYFIN_API_KEY="956d0168987f4e4680626653abb080f0"
|
||||
|
||||
# ━━━ Notifications ━━━
|
||||
# Discord webhook — leave blank to disable.
|
||||
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
|
||||
HOST2_DISCORD_WEBHOOK=""
|
||||
|
||||
# ━━━ Partnership ━━━
|
||||
# HOST2 is the mirror — HOST1 is always the owner unless --transfer has been run.
|
||||
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
|
||||
|
||||
# Auth containers reconfigured on onboard/offboard.
|
||||
# Format: "ContainerName|WebUIPort"
|
||||
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
|
||||
# On offboard → WebUI pointed back at localhost
|
||||
HOST2_PARTNERSHIP_AUTH_WEBUIS=(
|
||||
# fill in when HOST2 is back online
|
||||
# "NginxProxyManager|81"
|
||||
)
|
||||
|
||||
# Containers to stop on this server before the owner deploys the auth stack during onboard.
|
||||
# List whatever auth/proxy containers are currently running here.
|
||||
HOST2_PARTNERSHIP_REPLACE_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
"Mariadb-Authelia"
|
||||
"Mariadb-Authelia-Secondary"
|
||||
"Redis-Authelia"
|
||||
"Redis-Authelia-Secondary"
|
||||
"Lldap-Gmer4Lfe"
|
||||
)
|
||||
|
||||
# Arr containers to stop on this server before the owner deploys the arr stack during onboard.
|
||||
HOST2_PARTNERSHIP_ARR_REPLACE_CONTAINERS=(
|
||||
# "Sonarr"
|
||||
# "Radarr"
|
||||
# "Lidarr"
|
||||
# "Prowlarr"
|
||||
# "Bazarr"
|
||||
)
|
||||
|
||||
# Paths HOST1 should collect during the grace window after offboard.
|
||||
# Notified on offboard — no auto-deletion, HOST1 must collect manually within PARTNERSHIP_GRACE_HOURS.
|
||||
HOST2_PARTNERSHIP_MIRROR_BACKUPS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# Containers parked on this server when partnership is active.
|
||||
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
|
||||
HOST2_PARTNERSHIP_OWN_CONTAINERS=(
|
||||
# "Emby"
|
||||
# "NginxProxyManager"
|
||||
)
|
||||
|
||||
# This server's desired Emby admin account on the shared Emby instance.
|
||||
# Set these — owner reads them during --onboard to create the account.
|
||||
HOST2_PARTNERSHIP_EMBY_ADMIN_USER="" # desired Emby username
|
||||
HOST2_PARTNERSHIP_EMBY_ADMIN_PASS="" # desired Emby password
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Daily Sync Shares ━━━
|
||||
# Shares HOST2 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
|
||||
# Mesh model: every node pushes every media share — no ownership, no mirrors.
|
||||
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
|
||||
# arr_cleanup removes true orphans based on local arr state.
|
||||
# Any node can download content to any share — it propagates to all nodes on the next cycle.
|
||||
# Nextcloud excluded — personal data, not arr-managed, synced HOST1→HOST2 only as offsite backup.
|
||||
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
|
||||
# For shares needing container stops or custom options — add a profile in master.conf.
|
||||
HOST2_DAILY_SYNC_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Books
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Sports
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/Anime_Movies-Old
|
||||
)
|
||||
|
||||
# Personal encrypted shares — synced for offsite backup, independent of media shares.
|
||||
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
|
||||
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
|
||||
HOST2_PERSONAL_SHARES=(
|
||||
# /mnt/user/HOST2-Personal # uncomment after creating encrypted dataset
|
||||
)
|
||||
|
||||
# ━━━ Weekly Sync Shares ━━━
|
||||
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
|
||||
# Containers stopped both sides before sync — full clean state guaranteed.
|
||||
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
|
||||
HOST2_WEEKLY_SYNC_SHARES=(
|
||||
# fill in when HOST2 is back online
|
||||
# "/mnt/user/Media_Server/Emby"
|
||||
# "/mnt/user/appdata-Fallback/Critical-Data"
|
||||
)
|
||||
|
||||
# ━━━ Intermediate Sync Shares ━━━
|
||||
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
|
||||
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
|
||||
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
|
||||
HOST2_INTERMEDIATE_SYNC_SHARES=(
|
||||
# fill in when HOST2 is back online
|
||||
# Example: "/mnt/user/Emby_Metadata"
|
||||
)
|
||||
|
||||
# ━━━ Critical Sync Shares ━━━
|
||||
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
|
||||
# Format: "/path/to/share" or "/path/to/share|profile-name"
|
||||
HOST2_CRITICAL_SYNC_SHARES=(
|
||||
# fill in when HOST2 is back online
|
||||
# "/mnt/user/appdata-Fallback/Critical-Data|critical-fallback"
|
||||
# "/mnt/user/Media_Server/Emby|emby-fallback"
|
||||
)
|
||||
|
||||
# ━━━ Backup Verify ━━━
|
||||
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
|
||||
# Leave empty to use HOST2_DAILY_SYNC_SHARES automatically.
|
||||
# Sample size and minimum file size defined in master.conf.
|
||||
HOST2_BACKUP_VERIFY_SHARES=(
|
||||
# leave empty to use HOST2_DAILY_SYNC_SHARES automatically
|
||||
)
|
||||
|
||||
# ━━━ HOST2 Rsync Profile — host2-appdata ━━━
|
||||
# HOST2-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
|
||||
# Use for appdata unique to HOST2.
|
||||
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
|
||||
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST2-Appdata --profile=host2-appdata
|
||||
PROFILE_RSYNC_OPTS[host2-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host2-appdata]:-8000}"
|
||||
PROFILE_BW_LIMIT[host2-appdata]=8000
|
||||
PROFILE_RETRY_COUNT[host2-appdata]=3
|
||||
PROFILE_SLEEP[host2-appdata]=300
|
||||
PROFILE_CRITICAL_CONTAINER_NAMES[host2-appdata]="" # fill in when HOST2 is back online
|
||||
PROFILE_DELAYED_CONTAINERS[host2-appdata]=""
|
||||
PROFILE_CONTAINER_DELAY[host2-appdata]=5
|
||||
PROFILE_EXCLUDE_DIRS[host2-appdata]="logs *.tmp"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Docker Daily Restart ━━━
|
||||
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
|
||||
# Fill in when HOST2 is back online — add containers that degrade without daily restart.
|
||||
HOST2_DAILY_RESTART_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
# add HOST2 daily restart containers here
|
||||
)
|
||||
|
||||
# ━━━ Docker Weekly Restart ━━━
|
||||
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
|
||||
# Containers already stopped for weekly sync — restart adds zero extra downtime.
|
||||
HOST2_WEEKLY_RESTART_CONTAINERS=(
|
||||
# add HOST2 weekly restart containers here
|
||||
)
|
||||
|
||||
# ━━━ Docker Watchdog ━━━
|
||||
# Per-HOST2 container configuration for docker_watchdog.sh.
|
||||
# Shared thresholds and toggles live in master.conf.
|
||||
|
||||
# Memory hard limits in MB — immediate restart if exceeded.
|
||||
# Set at "container is clearly broken" not "container is busy".
|
||||
# 20GB=20480 16GB=16384 12GB=12288 10GB=10240 8GB=8192 4GB=4096 2GB=2048 1GB=1024
|
||||
declare -A HOST2_WATCHDOG_CONTAINERS=(
|
||||
["Emby"]=16384 # fill in correct limit when HOST2 is back online
|
||||
)
|
||||
|
||||
# HTTP health check URLs — checked every cycle, strike system before restart.
|
||||
# Only add containers with a meaningful web interface to check.
|
||||
declare -A HOST2_WATCHDOG_CONTAINER_URLS=(
|
||||
["Emby"]="http://localhost:8096"
|
||||
)
|
||||
|
||||
# Required containers — must always be running on HOST2.
|
||||
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
|
||||
# Listed in dependency order — dependencies before dependents.
|
||||
HOST2_WATCHDOG_REQUIRED_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
# add HOST2 required containers here when back online
|
||||
)
|
||||
|
||||
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
|
||||
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
|
||||
HOST2_WATCHDOG_SCAN_IGNORE=(
|
||||
# add HOST2 scan ignore containers here when back online
|
||||
)
|
||||
|
||||
# Dependency ordering — skip restarting a container if its dependency is also down.
|
||||
# Prevents watchdog from restarting dependent services before their dependencies are up.
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime.
|
||||
declare -A HOST2_WATCHDOG_DEPENDENCIES=(
|
||||
# add HOST2 dependencies here when containers are defined
|
||||
# ["Authelia"]="Mariadb-Authelia Redis-Authelia"
|
||||
)
|
||||
|
||||
# Per-container appdata growth suppress ceilings in MB.
|
||||
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
|
||||
# Use when a container legitimately has large stable data and you want to suppress false-positive
|
||||
# growth alerts. Add entries here only when a container triggers warnings it shouldn't.
|
||||
declare -A HOST2_WATCHDOG_APPDATA_SIZES=(
|
||||
# add HOST2 suppress entries here only as needed
|
||||
)
|
||||
|
||||
# ━━━ Docker Network Connect ━━━
|
||||
# Containers connected to custom networks at array start by docker_network_connect.sh.
|
||||
# Networks created if they don't exist — idempotent, safe to re-run.
|
||||
HOST2_NETWORK_CONNECT_CONTAINERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
HOST2_NETWORK_CONNECT_NETWORKS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ DDNS ━━━
|
||||
# DDNS containers HOST2 manages — started/stopped by fallback.sh per DDNS absolute rules:
|
||||
# Internet loss → stop immediately
|
||||
# Failover → HOST1 starts HOST2's DDNS as Tier 1 (before any other containers)
|
||||
# Handback → stop HOST2's DDNS on HOST1 → rsync → start containers → start local DDNS last
|
||||
HOST2_DDNS_CONTAINERS=(
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ Internet Loss ━━━
|
||||
# Containers stopped immediately on HOST2 when internet connection is lost.
|
||||
# Prevents external-facing services from operating without connectivity.
|
||||
FALLBACK_HOST2_STOP_ON_NO_NET=(
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ Fallback Tiers — HOST2 Runs for HOST1 ━━━
|
||||
# Containers HOST2 starts when HOST1 goes down.
|
||||
# Tier 1 is always immediate — vital services cannot wait.
|
||||
# Higher tiers activate after HOST1_TIER*_DELAY minutes (set in host1.conf).
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER1=(
|
||||
"Gmer4Lfe.com"
|
||||
"Gitea" # source of truth — must be reachable even when HOST1 auth stack is down
|
||||
"Emby"
|
||||
"VaultWarden-Gmer4Lfe"
|
||||
"Dispatcharr"
|
||||
"Dispatcharr-Basic"
|
||||
"Dispatcharr-Iptv-Users"
|
||||
"ErsatzTV-Emby"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER2=(
|
||||
"Postgres-NextCloud"
|
||||
"NextCloud"
|
||||
"PostgreSQL_Immich"
|
||||
"Immich-Gmer4Lfe"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER3=(
|
||||
"Gitea"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER4=(
|
||||
"Sonarr"
|
||||
"Radarr"
|
||||
"Lidarr"
|
||||
"Readarr"
|
||||
"Prowlarr"
|
||||
"Bazarr"
|
||||
"SABnzbd-Gmer4Lfe"
|
||||
"Qbittorrent-Gmer4Lfe"
|
||||
"LidaTube"
|
||||
"Pinchflat"
|
||||
"ChannelTube"
|
||||
)
|
||||
|
||||
# ━━━ Tier Delays — HOST2's Containers on HOST1 ━━━
|
||||
# How long HOST2 must be down before each tier activates on HOST1 — in minutes.
|
||||
# Tier 1 is always immediate — no delay var needed.
|
||||
HOST2_TIER2_DELAY=240 # 4 hours — productivity services
|
||||
HOST2_TIER3_DELAY=720 # 12 hours — secondary services
|
||||
HOST2_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
|
||||
|
||||
# ━━━ Rsync Writeback — HOST2 Appdata Back on Handback ━━━
|
||||
# Syncs HOST2 appdata BACK to HOST2 when it comes back online after a failover.
|
||||
# Containers stopped before writeback — clean source, no competing writes.
|
||||
#
|
||||
# HOST2_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
|
||||
# is more reliable than dirty sync data for brief outages.
|
||||
HOST2_TIER1_WRITEBACK_DELAY=60 # skip writeback if outage under 1hr
|
||||
|
||||
# Tier 4 automatically syncs HOST2_DAILY_SYNC_SHARES — only list paths NOT in that array.
|
||||
FALLBACK_HOST2_WRITEBACK_TIER1=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_WRITEBACK_TIER2=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Important"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_WRITEBACK_TIER3=(
|
||||
# "location-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_WRITEBACK_TIER4=(
|
||||
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Media Permissions ━━━
|
||||
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
|
||||
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
|
||||
HOST2_MEDIA_PERMISSION_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
# ━━━ Media Cleaner ━━━
|
||||
# Folder lists for media_cleaner.sh — two profiles: anime and media.
|
||||
# File patterns shared across all servers — defined in master.conf.
|
||||
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
|
||||
HOST2_ANIME_CLEAN_FOLDERS=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
HOST2_MEDIA_CLEAN_FOLDERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Certificate Monitor ━━━
|
||||
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
|
||||
# Checks the actual certificate served, not what NPM thinks it has.
|
||||
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
|
||||
HOST2_CERT_MONITOR_DOMAINS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ━━━ SMART Health ━━━
|
||||
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
|
||||
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
|
||||
HOST2_SMART_IGNORE_DRIVES=(
|
||||
"sda" # boot USB — SMART not meaningful on flash drives
|
||||
)
|
||||
|
||||
# ━━━ ZFS Report ━━━
|
||||
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
|
||||
# Pool health thresholds defined in master.conf.
|
||||
HOST2_ZFS_REPORT_IGNORE_POOLS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
|
||||
# Adjust HOST2_RAMDISK_WARN_GB and HOST2_RAMDISK_LOW_GB together if this changes.
|
||||
HOST2_RAMDISK_SIZE="8G"
|
||||
|
||||
# Usage thresholds — coupled to HOST2_RAMDISK_SIZE, adjust all three together if size changes.
|
||||
# Hysteresis gap (6.8 - 5.5 = 1.3GB) prevents flip-flop between ramdisk and SSD.
|
||||
HOST2_RAMDISK_WARN_GB=6.8 # flip to SSD when ramdisk usage reaches this
|
||||
HOST2_RAMDISK_LOW_GB=5.5 # flip back to ramdisk when usage drops to this
|
||||
|
||||
# SSD fallback path — where transcodes land when ramdisk exceeds HOST2_RAMDISK_WARN_GB.
|
||||
# Must be on cache pool — array disks too slow for active transcode writes.
|
||||
HOST2_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
|
||||
|
||||
# Media servers sharing the ramdisk transcode space on HOST2.
|
||||
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
|
||||
# Entries with placeholder API keys are skipped automatically.
|
||||
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
|
||||
HOST2_TRANSCODE_SERVERS=(
|
||||
"${HOST2_EMBY_CONTAINER}|${HOST2_EMBY_URL}|${HOST2_EMBY_API_KEY}|emby"
|
||||
"${HOST2_JELLYFIN_CONTAINER}|${HOST2_JELLYFIN_URL}|${HOST2_JELLYFIN_API_KEY}|jellyfin"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
|
||||
# detect_hosts() selects HOST2 vars when running on HOST2.
|
||||
# Lidarr does not run on HOST2 — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
|
||||
#
|
||||
# PATH MAPS — container path → host path translation.
|
||||
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
|
||||
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
|
||||
HOST2_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
|
||||
HOST2_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
|
||||
|
||||
# ━━━ Sonarr ━━━
|
||||
HOST2_SONARR_URL="http://localhost:8989"
|
||||
HOST2_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
|
||||
HOST2_SONARR_TV_ROOT="/mnt/user/Anime_Shows"
|
||||
|
||||
declare -A HOST2_SONARR_PATH_MAP=(
|
||||
# fill in when HOST2 is back online
|
||||
# ["/tv"]="/mnt/user/Anime_Shows"
|
||||
)
|
||||
|
||||
# ━━━ Radarr ━━━
|
||||
HOST2_RADARR_URL="http://localhost:7878"
|
||||
HOST2_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
|
||||
HOST2_RADARR_MOVIES_ROOT="/mnt/user/Anime_Movies"
|
||||
|
||||
declare -A HOST2_RADARR_PATH_MAP=(
|
||||
# fill in when HOST2 is back online
|
||||
# ["/anime-movies"]="/mnt/user/Anime_Movies"
|
||||
)
|
||||
|
||||
# ━━━ Arr Recovery Toggles ━━━
|
||||
# false = skip that arr on this host — exits cleanly without error
|
||||
HOST2_SONARR_RECOVERY=true
|
||||
HOST2_RADARR_RECOVERY=true
|
||||
# HOST2_LIDARR_RECOVERY not set — Lidarr does not run on HOST2
|
||||
|
||||
# ==============================================================================================
|
||||
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Per-host check toggles and NIC config for system_watchdog.sh.
|
||||
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
|
||||
# HOST2: i5 10th gen 64GB — being rebuilt, lighter workload, no ZFS cache pools.
|
||||
#
|
||||
# Conservative defaults during rebuild — re-enable checks as HOST2 stabilises.
|
||||
# Three-tier response — all critical checks enabled regardless of rebuild state:
|
||||
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
|
||||
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
|
||||
# Tier 3 (standard strike system): selectively disabled during rebuild
|
||||
#
|
||||
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
|
||||
|
||||
# ━━━ Primary NIC ━━━
|
||||
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
|
||||
# Common values: eth0, bond0, br0, eno1
|
||||
HOST2_SYS_WATCHDOG_NIC="eth0"
|
||||
|
||||
# ━━━ Tier 1 — Critical Checks ━━━
|
||||
# All critical checks always enabled — these protect against acute failure regardless of
|
||||
# rebuild state. Disabling any is not recommended.
|
||||
|
||||
# Docker daemon unresponsive → try restart, reboot if restart fails.
|
||||
HOST2_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
|
||||
|
||||
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_ROOTFS=true
|
||||
|
||||
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
|
||||
|
||||
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_FD=true
|
||||
|
||||
# /boot read-only detected → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_BOOT=true
|
||||
|
||||
# ━━━ Tier 2 — Urgent OOM Check ━━━
|
||||
# Both must be enabled for Tier 2 bypass to function.
|
||||
|
||||
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
|
||||
HOST2_SYS_WATCHDOG_CHECK_OOM=true
|
||||
|
||||
# Free RAM check — 64GB RAM on HOST2, tiers adjusted relative to HOST1.
|
||||
# Update master.conf SYS_WATCHDOG_MEM_* thresholds if HOST2 needs different values.
|
||||
# Currently inheriting shared master.conf values — may want lower thresholds on 64GB.
|
||||
HOST2_SYS_WATCHDOG_CHECK_RAM=true
|
||||
|
||||
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
|
||||
# Several checks disabled during rebuild — enable progressively as HOST2 stabilises.
|
||||
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action.
|
||||
|
||||
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
|
||||
HOST2_SYS_WATCHDOG_CHECK_LOG=true
|
||||
|
||||
# ZFS ARC memory check.
|
||||
# DISABLED — HOST2 has no ZFS cache pools. Enable if ZFS pools are added later.
|
||||
HOST2_SYS_WATCHDOG_CHECK_ARC=false
|
||||
|
||||
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
|
||||
HOST2_SYS_WATCHDOG_CHECK_CPU_TEMP=true
|
||||
|
||||
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
|
||||
# DISABLED — rebuild operations cause legitimate load spikes. Enable after rebuild.
|
||||
HOST2_SYS_WATCHDOG_CHECK_LOAD=false
|
||||
|
||||
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
|
||||
HOST2_SYS_WATCHDOG_CHECK_ZOMBIES=true
|
||||
|
||||
# docker_watchdog.sh persistent skip list check.
|
||||
# DISABLED during rebuild — skip list may be unreliable mid-rebuild, avoid false reboots.
|
||||
# Enable once HOST2 is fully operational and docker_watchdog.sh is running stably.
|
||||
HOST2_SYS_WATCHDOG_CHECK_CONTAINERS=false
|
||||
|
||||
# /tmp filesystem usage with auto-clear attempt.
|
||||
HOST2_SYS_WATCHDOG_CHECK_TMP=true
|
||||
|
||||
# Array disk error count delta in /proc/mdstat.
|
||||
HOST2_SYS_WATCHDOG_CHECK_MDSTAT=true
|
||||
|
||||
# Primary NIC operstate — uses HOST2_SYS_WATCHDOG_NIC above.
|
||||
HOST2_SYS_WATCHDOG_CHECK_NETWORK=true
|
||||
|
||||
# sshd running check — restart attempt before escalating.
|
||||
HOST2_SYS_WATCHDOG_CHECK_SSHD=true
|
||||
|
||||
# Runaway process detection.
|
||||
# DISABLED — rebuild workloads may legitimately peg CPU. Enable after rebuild.
|
||||
HOST2_SYS_WATCHDOG_CHECK_RUNAWAY=false
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
|
||||
|
||||
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
|
||||
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
|
||||
HOST2_RW_PAUSE_CONTAINERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
|
||||
# Full stop — these are optional/heavy services that free significant RAM when stopped.
|
||||
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
|
||||
HOST2_RW_STOP_CONTAINERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ──────────────────────── End Of HOST2 Variables ──────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
@@ -0,0 +1,664 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ========================== HOST2 CONFIGURATION — unRAID-Jayred365 ===========================
|
||||
# ==============================================================================================
|
||||
# HOST2-specific variables — credentials, container names, share paths, failover lists.
|
||||
# Sourced after master.conf — values here extend shared profile arrays and add HOST2-specific
|
||||
# identity, credentials, and container configuration.
|
||||
#
|
||||
# Sparse checkout (git) ensures HOST1 never receives this file.
|
||||
# HOST1 never sees HOST2 credentials — clean separation at the file level.
|
||||
#
|
||||
# DO NOT put shared config here — thresholds, toggles, profiles belong in master.conf.
|
||||
# DO NOT put HOST1 variables here — they belong in host1.conf.
|
||||
#
|
||||
# ── STATUS ────────────────────────────────────────────────────────────────────────────────────
|
||||
# HOST2 is currently being rebuilt — most sections scaffolded, fill in when back online.
|
||||
# When ready: set FALLBACK_ENABLED=true and DAILY_RSYNC_ENABLED=true in master.conf.
|
||||
#
|
||||
# ── INDEX ─────────────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# ── IDENTITY & CONNECTIVITY ────────────────────────────────────────────────────────────────
|
||||
# IDENTITY hostname, SSH key
|
||||
# EMBY container name, URL, API key
|
||||
# NOTIFICATIONS Discord webhook
|
||||
# PARTNERSHIP auth containers, backup paths
|
||||
#
|
||||
# ── RSYNC ──────────────────────────────────────────────────────────────────────────────────
|
||||
# DAILY SYNC SHARES media shares HOST2 owns and pushes to HOST1
|
||||
# WEEKLY SYNC SHARES appdata shares synced weekly (Sunday 2:30am)
|
||||
# CRITICAL SYNC SHARES appdata shares synced every 30 minutes
|
||||
# BACKUP VERIFY shares for checksum verification against remote
|
||||
# HOST2 RSYNC PROFILE host2-appdata profile for HOST2-specific appdata syncs
|
||||
#
|
||||
# ── DOCKER ─────────────────────────────────────────────────────────────────────────────────
|
||||
# DOCKER DAILY RESTART containers restarted daily
|
||||
# DOCKER WEEKLY RESTART containers restarted weekly
|
||||
# DOCKER WATCHDOG memory limits, health URLs, required containers, ignore list
|
||||
# DOCKER NETWORK CONNECT networks and containers for docker_network_connect.sh
|
||||
#
|
||||
# ── FALLBACK ───────────────────────────────────────────────────────────────────────────────
|
||||
# DDNS DDNS containers managed by HOST2
|
||||
# INTERNET LOSS containers stopped when internet is lost
|
||||
# FALLBACK TIERS what HOST2 runs for HOST1 per tier
|
||||
# TIER DELAYS how long HOST2 must be down before each tier activates on HOST1
|
||||
# RSYNC WRITEBACK HOST2 appdata synced back on handback
|
||||
#
|
||||
# ── MEDIA ──────────────────────────────────────────────────────────────────────────────────
|
||||
# MEDIA PERMISSIONS share list for media_shares_permissions.sh
|
||||
# MEDIA CLEANER folder lists for media_cleaner.sh
|
||||
#
|
||||
# ── MONITORS ───────────────────────────────────────────────────────────────────────────────
|
||||
# CERTIFICATE MONITOR domains checked for SSL expiry
|
||||
# SMART HEALTH drives to skip in SMART monitoring
|
||||
# ZFS REPORT pools to exclude from ZFS health report
|
||||
#
|
||||
# ── TRANSCODES ─────────────────────────────────────────────────────────────────────────────
|
||||
# TRANSCODES ramdisk size, thresholds, SSD path, server array
|
||||
#
|
||||
# ── ARR STACK ──────────────────────────────────────────────────────────────────────────────
|
||||
# SONARR URL, API key, path map
|
||||
# RADARR URL, API key, path map
|
||||
# ARR RECOVERY per-arr recovery toggles (no Lidarr on HOST2)
|
||||
#
|
||||
# ── SYSTEM WATCHDOG ────────────────────────────────────────────────────────────────────────
|
||||
# SYSTEM WATCHDOG per-host check toggles and NIC configuration
|
||||
#
|
||||
# ── RESOURCE MANAGER ───────────────────────────────────────────────────────────────────────
|
||||
# RESOURCE MANAGER containers paused/stopped under memory pressure
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
# ==============================================================================================
|
||||
# ── IDENTITY & CONNECTIVITY ───────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Identity ━━━
|
||||
# HOST2 hostname lives in master.conf (not a credential — safe for all servers).
|
||||
# SSH key used for all server-to-server operations — rsync, failover container commands.
|
||||
# Must be in /root/.ssh/ and authorised in HOST1's /root/.ssh/authorized_keys.
|
||||
HOST2_SSH_KEY="/root/.ssh/Jayred365-rsync-key"
|
||||
HOST2_OWNER="jayred365"
|
||||
HOST2_OWNER_EMAIL="" # fill in when HOST2 is back online
|
||||
|
||||
# ━━━ Unraid API ━━━
|
||||
# Generate in Unraid: Settings → Management Access → API Keys → + New Key
|
||||
HOST2_UNRAID_API_KEY="2bdf5119d61eefa3023434748bd1c171bd23dc0b2ebc8586e24abe07df986acc"
|
||||
|
||||
# ━━━ Emby ━━━
|
||||
# Referenced by transcode_manager.sh, emby_session_report.sh, emby_database_repair.sh,
|
||||
# weekly_sync_maintenance.sh, and HOST2_TRANSCODE_SERVERS below.
|
||||
# API key: Emby Dashboard → API Keys → + New Key
|
||||
HOST2_EMBY_CONTAINER="Emby-Jayred365"
|
||||
HOST2_EMBY_URL="http://localhost:8096"
|
||||
HOST2_EMBY_API_KEY="your-host2-emby-api-key"
|
||||
|
||||
# ━━━ Jellyfin ━━━
|
||||
# API key: Jellyfin Dashboard → Administration → API Keys → + New Key
|
||||
HOST2_JELLYFIN_CONTAINER="Jellyfin"
|
||||
HOST2_JELLYFIN_URL="http://localhost:8095"
|
||||
HOST2_JELLYFIN_API_KEY="956d0168987f4e4680626653abb080f0"
|
||||
|
||||
# ━━━ Notifications ━━━
|
||||
# Discord webhook — leave blank to disable.
|
||||
# Per-host so HOST1 and HOST2 can post to different channels or only one server notifies.
|
||||
HOST2_DISCORD_WEBHOOK=""
|
||||
|
||||
# ━━━ Partnership ━━━
|
||||
# HOST2 is the mirror — HOST1 is always the owner unless --transfer has been run.
|
||||
# See README-Partnership.md and master.conf PARTNERSHIP section for full lifecycle docs.
|
||||
|
||||
# Auth containers reconfigured on onboard/offboard.
|
||||
# Format: "ContainerName|WebUIPort"
|
||||
# On onboard → WebUI pointed at owner's Tailscale IP (mirror clicks NPM, gets owner's auth)
|
||||
# On offboard → WebUI pointed back at localhost
|
||||
HOST2_PARTNERSHIP_AUTH_WEBUIS=(
|
||||
# fill in when HOST2 is back online
|
||||
# "NginxProxyManager|81"
|
||||
)
|
||||
|
||||
# Containers to stop on this server before the owner deploys the auth stack during onboard.
|
||||
# List whatever auth/proxy containers are currently running here.
|
||||
HOST2_PARTNERSHIP_REPLACE_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
"Authelia"
|
||||
"Authelia-Secondary"
|
||||
"Mariadb-Authelia"
|
||||
"Mariadb-Authelia-Secondary"
|
||||
"Redis-Authelia"
|
||||
"Redis-Authelia-Secondary"
|
||||
"Lldap-Gmer4Lfe"
|
||||
)
|
||||
|
||||
# Arr containers to stop on this server before the owner deploys the arr stack during onboard.
|
||||
HOST2_PARTNERSHIP_ARR_REPLACE_CONTAINERS=(
|
||||
# "Sonarr"
|
||||
# "Radarr"
|
||||
# "Lidarr"
|
||||
# "Prowlarr"
|
||||
# "Bazarr"
|
||||
)
|
||||
|
||||
# Paths HOST1 should collect during the grace window after offboard.
|
||||
# Notified on offboard — no auto-deletion, HOST1 must collect manually within PARTNERSHIP_GRACE_HOURS.
|
||||
HOST2_PARTNERSHIP_MIRROR_BACKUPS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# Containers parked on this server when partnership is active.
|
||||
# Stopped on onboard (owner deploys its stack instead), restarted on offboard.
|
||||
HOST2_PARTNERSHIP_OWN_CONTAINERS=(
|
||||
# "Emby"
|
||||
# "NginxProxyManager"
|
||||
)
|
||||
|
||||
# This server's desired Emby admin account on the shared Emby instance.
|
||||
# Set these — owner reads them during --onboard to create the account.
|
||||
HOST2_PARTNERSHIP_EMBY_ADMIN_USER="" # desired Emby username
|
||||
HOST2_PARTNERSHIP_EMBY_ADMIN_PASS="" # desired Emby password
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RSYNC ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Daily Sync Shares ━━━
|
||||
# Shares HOST2 pushes to all other nodes every night (1am via daily_sync_maintenance.sh).
|
||||
# Mesh model: every node pushes every media share — no ownership, no mirrors.
|
||||
# arr_sync ensures all arr libraries converge (union). rsync spreads files (additive, no --delete).
|
||||
# arr_cleanup removes true orphans based on local arr state.
|
||||
# Any node can download content to any share — it propagates to all nodes on the next cycle.
|
||||
# Nextcloud excluded — personal data, not arr-managed, synced HOST1→HOST2 only as offsite backup.
|
||||
# Uses DEFAULT_RSYNC_OPTS from master.conf — no profile needed.
|
||||
# For shares needing container stops or custom options — add a profile in master.conf.
|
||||
HOST2_DAILY_SYNC_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
/mnt/user/Books
|
||||
/mnt/user/Intros
|
||||
/mnt/user/Kids_Movies
|
||||
/mnt/user/Kids_Tv_Shows
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Music
|
||||
/mnt/user/Music_Videos
|
||||
/mnt/user/stand-up_comedy
|
||||
/mnt/user/Sports
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/Anime_Shows-Old
|
||||
/mnt/user/Anime_Movies-Old
|
||||
)
|
||||
|
||||
# Personal encrypted shares — synced for offsite backup, independent of media shares.
|
||||
# ZFS encrypted at dataset level — remote receives encrypted blocks, cannot read content.
|
||||
# See README-Rsync_Setup.md for ZFS encryption setup before uncommenting.
|
||||
HOST2_PERSONAL_SHARES=(
|
||||
# /mnt/user/HOST2-Personal # uncomment after creating encrypted dataset
|
||||
)
|
||||
|
||||
# ━━━ Weekly Sync Shares ━━━
|
||||
# Appdata shares synced during the weekly maintenance window (Sunday 2:30am).
|
||||
# Containers stopped both sides before sync — full clean state guaranteed.
|
||||
# Profiles drive container stops, excludes, and options — configured in master.conf RSYNC section.
|
||||
HOST2_WEEKLY_SYNC_SHARES=(
|
||||
# fill in when HOST2 is back online
|
||||
# "/mnt/user/Media_Server/Emby"
|
||||
# "/mnt/user/appdata-Fallback/Critical-Data"
|
||||
)
|
||||
|
||||
# ━━━ Intermediate Sync Shares ━━━
|
||||
# Shares synced every 4 hours by intermediate_sync_maintenance.sh.
|
||||
# Uses DEFAULT_RSYNC_OPTS (no --delete) — for sub-daily propagation of metadata or watch state.
|
||||
# Full media share sync stays in the daily window. Leave empty to skip mid-day rsync entirely.
|
||||
HOST2_INTERMEDIATE_SYNC_SHARES=(
|
||||
# fill in when HOST2 is back online
|
||||
# Example: "/mnt/user/Emby_Metadata"
|
||||
)
|
||||
|
||||
# ━━━ Critical Sync Shares ━━━
|
||||
# Appdata shares synced every 30 minutes by critical_sync_maintenance.sh.
|
||||
# Format: "/path/to/share" or "/path/to/share|profile-name"
|
||||
HOST2_CRITICAL_SYNC_SHARES=(
|
||||
# fill in when HOST2 is back online
|
||||
# "/mnt/user/appdata-Fallback/Critical-Data|critical-fallback"
|
||||
# "/mnt/user/Media_Server/Emby|emby-fallback"
|
||||
)
|
||||
|
||||
# ━━━ Backup Verify ━━━
|
||||
# Shares verified by backup_verify.sh — random file checksum comparison against remote.
|
||||
# Leave empty to use HOST2_DAILY_SYNC_SHARES automatically.
|
||||
# Sample size and minimum file size defined in master.conf.
|
||||
HOST2_BACKUP_VERIFY_SHARES=(
|
||||
# leave empty to use HOST2_DAILY_SYNC_SHARES automatically
|
||||
)
|
||||
|
||||
# ━━━ HOST2 Rsync Profile — host2-appdata ━━━
|
||||
# HOST2-specific appdata sync profile — extends the shared PROFILE_* arrays in master.conf.
|
||||
# Use for appdata unique to HOST2.
|
||||
# Shared appdata (auth stack, Emby) use dedicated profiles defined in master.conf.
|
||||
# Run manually: bash Rsync/rsync.sh /mnt/user/appdata-Fallback/HOST2-Appdata --profile=host2-appdata
|
||||
PROFILE_RSYNC_OPTS[host2-appdata]="-av --info=progress2 --bwlimit=${PROFILE_BW_LIMIT[host2-appdata]:-8000}"
|
||||
PROFILE_BW_LIMIT[host2-appdata]=8000
|
||||
PROFILE_RETRY_COUNT[host2-appdata]=3
|
||||
PROFILE_SLEEP[host2-appdata]=300
|
||||
PROFILE_CRITICAL_CONTAINER_NAMES[host2-appdata]="" # fill in when HOST2 is back online
|
||||
PROFILE_DELAYED_CONTAINERS[host2-appdata]=""
|
||||
PROFILE_CONTAINER_DELAY[host2-appdata]=5
|
||||
PROFILE_EXCLUDE_DIRS[host2-appdata]="logs *.tmp"
|
||||
|
||||
# ==============================================================================================
|
||||
# ── DOCKER ────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Docker Daily Restart ━━━
|
||||
# Containers restarted every day via DAILY_MAINTENANCE_SCRIPTS.
|
||||
# Fill in when HOST2 is back online — add containers that degrade without daily restart.
|
||||
HOST2_DAILY_RESTART_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
# add HOST2 daily restart containers here
|
||||
)
|
||||
|
||||
# ━━━ Docker Weekly Restart ━━━
|
||||
# Less critical services restarted weekly via WEEKLY_MAINTENANCE_SCRIPTS (Sunday 2:30am).
|
||||
# Containers already stopped for weekly sync — restart adds zero extra downtime.
|
||||
HOST2_WEEKLY_RESTART_CONTAINERS=(
|
||||
# add HOST2 weekly restart containers here
|
||||
)
|
||||
|
||||
# ━━━ Docker Watchdog ━━━
|
||||
# Per-HOST2 container configuration for docker_watchdog.sh.
|
||||
# Shared thresholds and toggles live in master.conf.
|
||||
|
||||
# Memory hard limits in MB — immediate restart if exceeded.
|
||||
# Set at "container is clearly broken" not "container is busy".
|
||||
# 20GB=20480 16GB=16384 12GB=12288 10GB=10240 8GB=8192 4GB=4096 2GB=2048 1GB=1024
|
||||
declare -A HOST2_WATCHDOG_CONTAINERS=(
|
||||
["Emby"]=16384 # fill in correct limit when HOST2 is back online
|
||||
)
|
||||
|
||||
# HTTP health check URLs — checked every cycle, strike system before restart.
|
||||
# Only add containers with a meaningful web interface to check.
|
||||
declare -A HOST2_WATCHDOG_CONTAINER_URLS=(
|
||||
["Emby"]="http://localhost:8096"
|
||||
)
|
||||
|
||||
# Required containers — must always be running on HOST2.
|
||||
# Strike system before restart — repeated failures go on skip list, auto-clears on recovery.
|
||||
# Listed in dependency order — dependencies before dependents.
|
||||
HOST2_WATCHDOG_REQUIRED_CONTAINERS=(
|
||||
"NginxProxyManager"
|
||||
# add HOST2 required containers here when back online
|
||||
)
|
||||
|
||||
# Containers to skip in Tier 2 global scan — legitimately stopped or frequently restarting.
|
||||
# Watchdog leaves these alone entirely — no restart attempts, no crash loop tracking.
|
||||
HOST2_WATCHDOG_SCAN_IGNORE=(
|
||||
# add HOST2 scan ignore containers here when back online
|
||||
)
|
||||
|
||||
# Dependency ordering — skip restarting a container if its dependency is also down.
|
||||
# Prevents watchdog from restarting dependent services before their dependencies are up.
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime.
|
||||
declare -A HOST2_WATCHDOG_DEPENDENCIES=(
|
||||
# add HOST2 dependencies here when containers are defined
|
||||
# ["Authelia"]="Mariadb-Authelia Redis-Authelia"
|
||||
)
|
||||
|
||||
# Per-container appdata growth suppress ceilings in MB.
|
||||
# ONLY needed in specific cases — growth rate detection covers all containers automatically.
|
||||
# Use when a container legitimately has large stable data and you want to suppress false-positive
|
||||
# growth alerts. Add entries here only when a container triggers warnings it shouldn't.
|
||||
declare -A HOST2_WATCHDOG_APPDATA_SIZES=(
|
||||
# add HOST2 suppress entries here only as needed
|
||||
)
|
||||
|
||||
# ━━━ Docker Network Connect ━━━
|
||||
# Containers connected to custom networks at array start by docker_network_connect.sh.
|
||||
# Networks created if they don't exist — idempotent, safe to re-run.
|
||||
HOST2_NETWORK_CONNECT_CONTAINERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
HOST2_NETWORK_CONNECT_NETWORKS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── FALLBACK ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ DDNS ━━━
|
||||
# DDNS containers HOST2 manages — started/stopped by fallback.sh per DDNS absolute rules:
|
||||
# Internet loss → stop immediately
|
||||
# Failover → HOST1 starts HOST2's DDNS as Tier 1 (before any other containers)
|
||||
# Handback → stop HOST2's DDNS on HOST1 → rsync → start containers → start local DDNS last
|
||||
HOST2_DDNS_CONTAINERS=(
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ Internet Loss ━━━
|
||||
# Containers stopped immediately on HOST2 when internet connection is lost.
|
||||
# Prevents external-facing services from operating without connectivity.
|
||||
FALLBACK_HOST2_STOP_ON_NO_NET=(
|
||||
"Gmer4Lfe.us"
|
||||
)
|
||||
|
||||
# ━━━ Fallback Tiers — HOST2 Runs for HOST1 ━━━
|
||||
# Containers HOST2 starts when HOST1 goes down.
|
||||
# Tier 1 is always immediate — vital services cannot wait.
|
||||
# Higher tiers activate after HOST1_TIER*_DELAY minutes (set in host1.conf).
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER1=(
|
||||
"Gmer4Lfe.com"
|
||||
"Gitea" # source of truth — must be reachable even when HOST1 auth stack is down
|
||||
"Emby"
|
||||
"VaultWarden-Gmer4Lfe"
|
||||
"Dispatcharr"
|
||||
"Dispatcharr-Basic"
|
||||
"Dispatcharr-Iptv-Users"
|
||||
"ErsatzTV-Emby"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER2=(
|
||||
"Postgres-NextCloud"
|
||||
"NextCloud"
|
||||
"PostgreSQL_Immich"
|
||||
"Immich-Gmer4Lfe"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER3=(
|
||||
"Gitea"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_COVERS_HOST1_TIER4=(
|
||||
"Sonarr"
|
||||
"Radarr"
|
||||
"Lidarr"
|
||||
"Readarr"
|
||||
"Prowlarr"
|
||||
"Bazarr"
|
||||
"SABnzbd-Gmer4Lfe"
|
||||
"Qbittorrent-Gmer4Lfe"
|
||||
"LidaTube"
|
||||
"Pinchflat"
|
||||
"ChannelTube"
|
||||
)
|
||||
|
||||
# ━━━ Tier Delays — HOST2's Containers on HOST1 ━━━
|
||||
# How long HOST2 must be down before each tier activates on HOST1 — in minutes.
|
||||
# Tier 1 is always immediate — no delay var needed.
|
||||
HOST2_TIER2_DELAY=240 # 4 hours — productivity services
|
||||
HOST2_TIER3_DELAY=720 # 12 hours — secondary services
|
||||
HOST2_TIER4_DELAY=1440 # 24 hours — arrs + downloaders
|
||||
|
||||
# ━━━ Rsync Writeback — HOST2 Appdata Back on Handback ━━━
|
||||
# Syncs HOST2 appdata BACK to HOST2 when it comes back online after a failover.
|
||||
# Containers stopped before writeback — clean source, no competing writes.
|
||||
#
|
||||
# HOST2_TIER1_WRITEBACK_DELAY: short outages skip Tier 1 writeback — primary state
|
||||
# is more reliable than dirty sync data for brief outages.
|
||||
HOST2_TIER1_WRITEBACK_DELAY=60 # skip writeback if outage under 1hr
|
||||
|
||||
# Tier 4 automatically syncs HOST2_DAILY_SYNC_SHARES — only list paths NOT in that array.
|
||||
FALLBACK_HOST2_WRITEBACK_TIER1=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Emby"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_WRITEBACK_TIER2=(
|
||||
# "/mnt/user/appdata-Fallback/Jayred365-Important"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_WRITEBACK_TIER3=(
|
||||
# "location-placeholder"
|
||||
)
|
||||
|
||||
FALLBACK_HOST2_WRITEBACK_TIER4=(
|
||||
"/mnt/user/appdata-Fallback/Arrs_Stack" # arr databases — downloads queued during outage
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MEDIA ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Media Permissions ━━━
|
||||
# Shares that media_shares_permissions.sh applies PERMISSIONS_MODE and PERMISSIONS_OWNER to.
|
||||
# Runs first in DAILY_MAINTENANCE_SCRIPTS — arr cleanup depends on correct ownership.
|
||||
HOST2_MEDIA_PERMISSION_SHARES=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
# ━━━ Media Cleaner ━━━
|
||||
# Folder lists for media_cleaner.sh — two profiles: anime and media.
|
||||
# File patterns shared across all servers — defined in master.conf.
|
||||
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/media_cleaner.sh anime|media
|
||||
HOST2_ANIME_CLEAN_FOLDERS=(
|
||||
/mnt/user/Anime_Movies
|
||||
/mnt/user/Anime_Shows
|
||||
)
|
||||
|
||||
HOST2_MEDIA_CLEAN_FOLDERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MONITORS ──────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# ━━━ Certificate Monitor ━━━
|
||||
# Domains checked via direct openssl connection — not relying on NPM's certificate state.
|
||||
# Checks the actual certificate served, not what NPM thinks it has.
|
||||
# Thresholds (CERT_WARN_DAYS, CERT_CRIT_DAYS) defined in master.conf.
|
||||
HOST2_CERT_MONITOR_DOMAINS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ━━━ SMART Health ━━━
|
||||
# Drives skipped in SMART attribute monitoring — hardware is server-specific.
|
||||
# Thresholds read from dynamix.cfg at runtime — fallbacks in master.conf.
|
||||
HOST2_SMART_IGNORE_DRIVES=(
|
||||
"sda" # boot USB — SMART not meaningful on flash drives
|
||||
)
|
||||
|
||||
# ━━━ ZFS Report ━━━
|
||||
# Pools excluded from the weekly ZFS health report — reduces noise from single-disk array pools.
|
||||
# Pool health thresholds defined in master.conf.
|
||||
HOST2_ZFS_REPORT_IGNORE_POOLS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── TRANSCODES ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
# Ramdisk size ceiling — tmpfs only uses RAM actually needed, not the full size upfront.
|
||||
# Adjust HOST2_RAMDISK_WARN_GB and HOST2_RAMDISK_LOW_GB together if this changes.
|
||||
HOST2_RAMDISK_SIZE="8G"
|
||||
|
||||
# Usage thresholds — coupled to HOST2_RAMDISK_SIZE, adjust all three together if size changes.
|
||||
# Hysteresis gap (6.8 - 5.5 = 1.3GB) prevents flip-flop between ramdisk and SSD.
|
||||
HOST2_RAMDISK_WARN_GB=6.8 # flip to SSD when ramdisk usage reaches this
|
||||
HOST2_RAMDISK_LOW_GB=5.5 # flip back to ramdisk when usage drops to this
|
||||
|
||||
# SSD fallback path — where transcodes land when ramdisk exceeds HOST2_RAMDISK_WARN_GB.
|
||||
# Must be on cache pool — array disks too slow for active transcode writes.
|
||||
HOST2_TRANSCODE_SSD="/mnt/cache/Temp_Storage/Emby/Transcodes/"
|
||||
|
||||
# Media servers sharing the ramdisk transcode space on HOST2.
|
||||
# Format: "ContainerName|URL|APIKey|Type" — Type: emby | jellyfin | plex
|
||||
# Entries with placeholder API keys are skipped automatically.
|
||||
# ⚠️ Tdarr does NOT belong here — keep Tdarr on SSD, not ramdisk.
|
||||
HOST2_TRANSCODE_SERVERS=(
|
||||
"${HOST2_EMBY_CONTAINER}|${HOST2_EMBY_URL}|${HOST2_EMBY_API_KEY}|emby"
|
||||
"${HOST2_JELLYFIN_CONTAINER}|${HOST2_JELLYFIN_URL}|${HOST2_JELLYFIN_API_KEY}|jellyfin"
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── ARR STACK ─────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Used by arr cleanup scripts and arrs_failed_stalled_recovery.sh.
|
||||
# detect_hosts() selects HOST2 vars when running on HOST2.
|
||||
# Lidarr does not run on HOST2 — HOST1_LIDARR_RECOVERY flag handles the exit cleanly.
|
||||
#
|
||||
# PATH MAPS — container path → host path translation.
|
||||
# Arr stores file paths using container-internal paths — scripts need host paths to scan.
|
||||
# Add one entry per root folder in arr Settings → Media Management → Root Folders.
|
||||
HOST2_FANART_API_KEY="Yd7147a43b692df0b364b94dc47efb81"
|
||||
HOST2_LASTFM_API_KEY="be6dc169c33ae263e690c30d18b7491d"
|
||||
|
||||
# ━━━ Sonarr ━━━
|
||||
HOST2_SONARR_URL="http://localhost:8989"
|
||||
HOST2_SONARR_API_KEY="130decd3db5b4c25afad64864cd03f9f"
|
||||
HOST2_SONARR_TV_ROOT="/mnt/user/Anime_Shows"
|
||||
|
||||
declare -A HOST2_SONARR_PATH_MAP=(
|
||||
# fill in when HOST2 is back online
|
||||
# ["/tv"]="/mnt/user/Anime_Shows"
|
||||
)
|
||||
|
||||
# ━━━ Radarr ━━━
|
||||
HOST2_RADARR_URL="http://localhost:7878"
|
||||
HOST2_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9"
|
||||
HOST2_RADARR_MOVIES_ROOT="/mnt/user/Anime_Movies"
|
||||
|
||||
declare -A HOST2_RADARR_PATH_MAP=(
|
||||
# fill in when HOST2 is back online
|
||||
# ["/anime-movies"]="/mnt/user/Anime_Movies"
|
||||
)
|
||||
|
||||
# ━━━ Arr Recovery Toggles ━━━
|
||||
# false = skip that arr on this host — exits cleanly without error
|
||||
HOST2_SONARR_RECOVERY=true
|
||||
HOST2_RADARR_RECOVERY=true
|
||||
# HOST2_LIDARR_RECOVERY not set — Lidarr does not run on HOST2
|
||||
|
||||
# ==============================================================================================
|
||||
# ── SYSTEM WATCHDOG ───────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Per-host check toggles and NIC config for system_watchdog.sh.
|
||||
# Aliased by detect_hosts() — script uses unprefixed SYS_WATCHDOG_* names.
|
||||
# HOST2: i5 10th gen 64GB — being rebuilt, lighter workload, no ZFS cache pools.
|
||||
#
|
||||
# Conservative defaults during rebuild — re-enable checks as HOST2 stabilises.
|
||||
# Three-tier response — all critical checks enabled regardless of rebuild state:
|
||||
# Tier 1 (bypass strikes, reboot now): docker daemon, rootfs full, kernel oops, FD, /boot
|
||||
# Tier 2 (bypass strikes with OOM): RAM critical + OOM kills in cycle
|
||||
# Tier 3 (standard strike system): selectively disabled during rebuild
|
||||
#
|
||||
# RAM tiers, OOM limits, and reboot loop settings in master.conf System Watchdog section.
|
||||
|
||||
# ━━━ Primary NIC ━━━
|
||||
# Network interface for NIC state check — verify with: ip link show | grep "^[0-9]"
|
||||
# Common values: eth0, bond0, br0, eno1
|
||||
HOST2_SYS_WATCHDOG_NIC="eth0"
|
||||
|
||||
# ━━━ Tier 1 — Critical Checks ━━━
|
||||
# All critical checks always enabled — these protect against acute failure regardless of
|
||||
# rebuild state. Disabling any is not recommended.
|
||||
|
||||
# Docker daemon unresponsive → try restart, reboot if restart fails.
|
||||
HOST2_SYS_WATCHDOG_CHECK_DOCKER_DAEMON=true
|
||||
|
||||
# rootfs at critical threshold (ROOTFS_CRITICAL_PCT=99) → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_ROOTFS=true
|
||||
|
||||
# Kernel BUG/Oops in dmesg delta since last cycle → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_KERNEL_OOPS=true
|
||||
|
||||
# File descriptor exhaustion at FD_CRITICAL_PCT (95%) → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_FD=true
|
||||
|
||||
# /boot read-only detected → reboot immediately.
|
||||
HOST2_SYS_WATCHDOG_CHECK_BOOT=true
|
||||
|
||||
# ━━━ Tier 2 — Urgent OOM Check ━━━
|
||||
# Both must be enabled for Tier 2 bypass to function.
|
||||
|
||||
# Track kernel OOM kills each cycle via /proc/vmstat oom_kill delta.
|
||||
HOST2_SYS_WATCHDOG_CHECK_OOM=true
|
||||
|
||||
# Free RAM check — 64GB RAM on HOST2, tiers adjusted relative to HOST1.
|
||||
# Update master.conf SYS_WATCHDOG_MEM_* thresholds if HOST2 needs different values.
|
||||
# Currently inheriting shared master.conf values — may want lower thresholds on 64GB.
|
||||
HOST2_SYS_WATCHDOG_CHECK_RAM=true
|
||||
|
||||
# ━━━ Tier 3 — Standard Checks (strike system) ━━━
|
||||
# Several checks disabled during rebuild — enable progressively as HOST2 stabilises.
|
||||
# Each check must fail SYS_WATCHDOG_STRIKE_LIMIT consecutive cycles before action.
|
||||
|
||||
# /var/log filesystem usage above SYS_WATCHDOG_LOG_PCT.
|
||||
HOST2_SYS_WATCHDOG_CHECK_LOG=true
|
||||
|
||||
# ZFS ARC memory check.
|
||||
# DISABLED — HOST2 has no ZFS cache pools. Enable if ZFS pools are added later.
|
||||
HOST2_SYS_WATCHDOG_CHECK_ARC=false
|
||||
|
||||
# CPU temperature above SYS_WATCHDOG_CPU_TEMP_MAX (95°C).
|
||||
HOST2_SYS_WATCHDOG_CHECK_CPU_TEMP=true
|
||||
|
||||
# Load average above SYS_WATCHDOG_LOAD_MULTIPLIER × core count.
|
||||
# DISABLED — rebuild operations cause legitimate load spikes. Enable after rebuild.
|
||||
HOST2_SYS_WATCHDOG_CHECK_LOAD=false
|
||||
|
||||
# Zombie process count above SYS_WATCHDOG_ZOMBIE_LIMIT (50).
|
||||
HOST2_SYS_WATCHDOG_CHECK_ZOMBIES=true
|
||||
|
||||
# docker_watchdog.sh persistent skip list check.
|
||||
# DISABLED during rebuild — skip list may be unreliable mid-rebuild, avoid false reboots.
|
||||
# Enable once HOST2 is fully operational and docker_watchdog.sh is running stably.
|
||||
HOST2_SYS_WATCHDOG_CHECK_CONTAINERS=false
|
||||
|
||||
# /tmp filesystem usage with auto-clear attempt.
|
||||
HOST2_SYS_WATCHDOG_CHECK_TMP=true
|
||||
|
||||
# Array disk error count delta in /proc/mdstat.
|
||||
HOST2_SYS_WATCHDOG_CHECK_MDSTAT=true
|
||||
|
||||
# Primary NIC operstate — uses HOST2_SYS_WATCHDOG_NIC above.
|
||||
HOST2_SYS_WATCHDOG_CHECK_NETWORK=true
|
||||
|
||||
# sshd running check — restart attempt before escalating.
|
||||
HOST2_SYS_WATCHDOG_CHECK_SSHD=true
|
||||
|
||||
# Runaway process detection.
|
||||
# DISABLED — rebuild workloads may legitimately peg CPU. Enable after rebuild.
|
||||
HOST2_SYS_WATCHDOG_CHECK_RUNAWAY=false
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Containers to manage under pressure — see master.conf RW_CRITICAL_CONTAINERS for exclusions.
|
||||
|
||||
# docker pause at medium pressure (RAM < RW_RAM_MEDIUM_GB or load > medium threshold)
|
||||
# Suspended in-place — instant to pause/unpause, no state lost, no restart delay.
|
||||
HOST2_RW_PAUSE_CONTAINERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# docker stop at hard pressure (RAM < RW_RAM_HARD_GB)
|
||||
# Full stop — these are optional/heavy services that free significant RAM when stopped.
|
||||
# resource_watchdog.sh restarts them when pressure fully clears (RAM >= RW_RAM_RECOVER_GB).
|
||||
HOST2_RW_STOP_CONTAINERS=(
|
||||
# fill in when HOST2 is back online
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
|
||||
# Fill in when HOST2 is back online.
|
||||
|
||||
# ━━━ NginxProxyManager ━━━
|
||||
HOST2_NPM_URL="http://localhost:81"
|
||||
HOST2_NPM_USER="" # NPM admin email
|
||||
HOST2_NPM_PASS="" # NPM admin password
|
||||
|
||||
# ━━━ lldap ━━━
|
||||
HOST2_LLDAP_URL="http://localhost:17170"
|
||||
HOST2_LLDAP_USER="admin" # lldap admin username
|
||||
HOST2_LLDAP_PASS="" # lldap admin password
|
||||
|
||||
# ━━━ Authelia ━━━
|
||||
HOST2_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
|
||||
HOST2_AUTHELIA_CONTAINER="Authelia"
|
||||
|
||||
# ==============================================================================================
|
||||
# ──────────────────────── End Of HOST2 Variables ──────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$action = ($_SERVER['REQUEST_METHOD'] === 'POST')
|
||||
? trim($_POST['action'] ?? '')
|
||||
: trim($_GET['action'] ?? '');
|
||||
|
||||
$cacheFile = STATE_DIR . '/cert_status.json';
|
||||
|
||||
// ── Read configured domains (without running checks) ─────────────────────────
|
||||
if ($action === 'domains') {
|
||||
$hostId = vv_detect_host();
|
||||
$hostIdUp = strtoupper($hostId);
|
||||
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
|
||||
// Extract CERT_WARN_DAYS / CERT_CRIT_DAYS from master
|
||||
preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
|
||||
preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
|
||||
|
||||
// Extract domains array from host conf
|
||||
$domains = [];
|
||||
if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
|
||||
preg_match_all('/"([^"]+)"/', $dm[1], $dd);
|
||||
$domains = $dd[1] ?? [];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'host_id' => $hostId,
|
||||
'domains' => $domains,
|
||||
'warn_days' => (int)($w[1] ?? 30),
|
||||
'crit_days' => (int)($c[1] ?? 7),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Run cert_monitor.sh now ───────────────────────────────────────────────────
|
||||
if ($action === 'run') {
|
||||
$script = SCRIPTS_DIR . '/Monitors/cert_monitor.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh not found']);
|
||||
exit;
|
||||
}
|
||||
set_time_limit(180);
|
||||
exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
|
||||
// Read freshly written cache
|
||||
$data = file_exists($cacheFile)
|
||||
? (json_decode(file_get_contents($cacheFile), true) ?: null)
|
||||
: null;
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'data' => $data,
|
||||
'output' => array_slice(array_filter(array_map('trim', $out)), 0, 30),
|
||||
'rc' => $rc,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Default: return cached status ─────────────────────────────────────────────
|
||||
if (!file_exists($cacheFile)) {
|
||||
// No cache yet — return configured domains so UI can show them unchecked
|
||||
$hostId = vv_detect_host();
|
||||
$hostIdUp = strtoupper($hostId);
|
||||
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
|
||||
preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w);
|
||||
preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c);
|
||||
$domains = [];
|
||||
if (preg_match('/' . $hostIdUp . '_CERT_MONITOR_DOMAINS\s*=\s*\(([^)]*)\)/s', $confRaw, $dm)) {
|
||||
preg_match_all('/"([^"]+)"/', $dm[1], $dd);
|
||||
foreach ($dd[1] ?? [] as $d) {
|
||||
$domains[] = ['domain' => $d, 'status' => 'UNKN', 'days' => null, 'expires' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'checked_at' => null,
|
||||
'host' => $hostId !== 'unknown' ? strtoupper($hostId) : null,
|
||||
'warn_days' => (int)($w[1] ?? 30),
|
||||
'crit_days' => (int)($c[1] ?? 7),
|
||||
'domains' => $domains,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($cacheFile), true) ?: [];
|
||||
echo json_encode(array_merge(['ok' => true], $data));
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+329
@@ -0,0 +1,329 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ================================= Storage Migration ==========================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Migrates Varaverk between internal NVMe and USB flash storage modes.
|
||||
#
|
||||
# Internal mode: SCRIPTS_DIR = /boot/config/plugins/varaverk
|
||||
# All scripts, conf, state, and git repo live on fast internal storage.
|
||||
# Direct git pull/push. Zero write-wear concern.
|
||||
#
|
||||
# Flash mode: SCRIPTS_DIR = /mnt/user/appdata/Varaverk
|
||||
# All scripts, conf, state, and git repo live in appdata.
|
||||
# Preserves USB flash lifetime. Array must be started for Varaverk to function.
|
||||
# git_pull_execute.sh syncs Plugin/ back to /boot/ after each pull so the
|
||||
# Unraid webUI always serves current PHP files.
|
||||
#
|
||||
# What this script updates:
|
||||
# varaverk.cfg SCRIPTS_DIR
|
||||
# master.conf TARGET_DIR
|
||||
# host*.conf HOST*_STORAGE_MODE_INTERNAL
|
||||
# varaverk.cron rebuilt via PHP (job paths regenerated for new SCRIPTS_DIR)
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# storage_migrate.sh --to=internal
|
||||
# Migrate to /boot/config/plugins/varaverk
|
||||
#
|
||||
# storage_migrate.sh --to=flash
|
||||
# Migrate to /mnt/user/appdata/Varaverk
|
||||
#
|
||||
# storage_migrate.sh --dry-run --to=<mode>
|
||||
# Show what would happen — no changes made
|
||||
#
|
||||
# storage_migrate.sh --status
|
||||
# Show current mode, paths, and boot device info
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
parse_args "$@"
|
||||
acquire_lock
|
||||
|
||||
VV_CFG="/boot/config/plugins/varaverk/varaverk.cfg"
|
||||
INTERNAL_DIR="/boot/config/plugins/varaverk"
|
||||
FLASH_DIR="/mnt/user/appdata/Varaverk"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Parse --to= from raw args (parse_args doesn't handle this flag)
|
||||
TO_MODE=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--to=internal) TO_MODE="internal" ;;
|
||||
--to=flash) TO_MODE="flash" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Boot device detection
|
||||
detect_boot_storage() {
|
||||
local boot_part boot_disk transport
|
||||
boot_part=$(findmnt -n -o SOURCE /boot 2>/dev/null)
|
||||
boot_disk=$(lsblk -no pkname "$boot_part" 2>/dev/null)
|
||||
transport=$(lsblk -dno TRAN "/dev/$boot_disk" 2>/dev/null | tr '[:upper:]' '[:lower:]')
|
||||
echo "${transport:-unknown}"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Status
|
||||
if [[ "$SHOW_STATUS" == true ]]; then
|
||||
TRANSPORT=$(detect_boot_storage)
|
||||
DETECTED=$([[ "$TRANSPORT" == "usb" ]] && echo "flash" || echo "internal")
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY STORAGE STATUS ━━━━━"
|
||||
echo "$ICON_GEAR SCRIPTS_DIR: $SCRIPTS_DIR"
|
||||
echo "$ICON_GEAR varaverk.cfg: $VV_CFG"
|
||||
echo "$ICON_HOST Boot device: transport=$TRANSPORT → detected=$DETECTED"
|
||||
echo "$ICON_GEAR Target dirs:"
|
||||
echo " internal: $INTERNAL_DIR"
|
||||
echo " flash: $FLASH_DIR"
|
||||
CONF_MODE=$(grep -m1 "${MY_ID}_STORAGE_MODE_INTERNAL" "$CONF_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' | tr -d '[:space:]')
|
||||
echo "$ICON_GEAR conf setting: ${MY_ID}_STORAGE_MODE_INTERNAL=${CONF_MODE:-not set}"
|
||||
if [[ "$SCRIPTS_DIR" == "$INTERNAL_DIR" ]]; then
|
||||
echo "$ICON_DONE Current mode: INTERNAL ✅"
|
||||
elif [[ "$SCRIPTS_DIR" == "$FLASH_DIR" ]]; then
|
||||
echo "$ICON_DONE Current mode: FLASH ✅"
|
||||
else
|
||||
echo "$ICON_WARN Current mode: CUSTOM ($SCRIPTS_DIR)"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
if [[ -z "$TO_MODE" ]]; then
|
||||
error "Usage: storage_migrate.sh --to=internal|flash [--dry-run] [--log]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SRC="$SCRIPTS_DIR"
|
||||
DST=$([[ "$TO_MODE" == "internal" ]] && echo "$INTERNAL_DIR" || echo "$FLASH_DIR")
|
||||
NEW_INTERNAL=$([[ "$TO_MODE" == "internal" ]] && echo "true" || echo "false")
|
||||
|
||||
log "$ICON_GEAR Config: to=${TO_MODE} src=${SRC} dst=${DST} dry-run=${DRY_RUN}"
|
||||
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SYNC Storage Migration ━━━━━"
|
||||
echo "$ICON_GEAR From: $SRC"
|
||||
echo "$ICON_GEAR To: $DST"
|
||||
echo "$ICON_GEAR Mode: $TO_MODE"
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
echo ""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pre-flight checks
|
||||
if [[ "$SRC" == "$DST" ]]; then
|
||||
info "Already in $TO_MODE mode — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$TO_MODE" == "flash" ]]; then
|
||||
if ! mountpoint -q /mnt/user 2>/dev/null; then
|
||||
error "Array not started — /mnt/user is not mounted. Start the array before migrating to flash."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SRC/load_config.sh" ]]; then
|
||||
error "Source directory looks invalid: $SRC (load_config.sh not found)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Git push — ensure remote has everything before we touch the local repo
|
||||
echo "━━━ $ICON_SYNC Step 1: Git push ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if [[ -d "$SRC/.git" ]]; then
|
||||
log "Pushing to Gitea before migration..."
|
||||
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT" \
|
||||
git -C "$SRC" push origin main 2>&1 | while IFS= read -r line; do echo " $line"; done; then
|
||||
echo " Git push complete ✅"
|
||||
else
|
||||
warn "Git push failed — continuing (data safe locally, push manually after migration)"
|
||||
fi
|
||||
else
|
||||
warn "No .git directory in $SRC — skipping push"
|
||||
fi
|
||||
else
|
||||
warn "DRY RUN — would push $SRC to Gitea"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Rsync content to destination
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Step 2: Copy files ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
mkdir -p "$DST"
|
||||
echo " rsync: $SRC/ → $DST/"
|
||||
if rsync -av --delete \
|
||||
--exclude='.git' \
|
||||
"$SRC/" "$DST/" 2>&1 | \
|
||||
grep -v "/$" | \
|
||||
while IFS= read -r line; do log "$line"; done; then
|
||||
echo " Files copied ✅"
|
||||
else
|
||||
error "rsync failed — aborting migration"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy .git separately (rsync --exclude='.git' above skipped it)
|
||||
echo " Copying .git..."
|
||||
if cp -a "$SRC/.git" "$DST/.git" 2>/dev/null || \
|
||||
rsync -a "$SRC/.git/" "$DST/.git/" 2>/dev/null; then
|
||||
echo " .git copied ✅"
|
||||
else
|
||||
error ".git copy failed — aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Mark git safe directory
|
||||
git config --global --add safe.directory "$DST" 2>/dev/null
|
||||
else
|
||||
warn "DRY RUN — would rsync $SRC/ → $DST/ (including .git)"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: Update varaverk.cfg
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 3: Update varaverk.cfg ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if grep -q '^SCRIPTS_DIR' "$VV_CFG"; then
|
||||
sed -i "s|^SCRIPTS_DIR=.*|SCRIPTS_DIR=\"$DST\"|" "$VV_CFG"
|
||||
else
|
||||
echo "SCRIPTS_DIR=\"$DST\"" >> "$VV_CFG"
|
||||
fi
|
||||
echo " SCRIPTS_DIR → $DST ✅"
|
||||
else
|
||||
warn "DRY RUN — would set SCRIPTS_DIR=\"$DST\" in $VV_CFG"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Update TARGET_DIR, DATA_DIR, STATE_DIR in master.conf (new location)
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 4: Update master.conf paths ━━━"
|
||||
NEW_MASTER="$DST/Configurations/master.conf"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if [[ -f "$NEW_MASTER" ]]; then
|
||||
sed -i "s|^\(\s*TARGET_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST\"|" "$NEW_MASTER"
|
||||
sed -i "s|^\(\s*DATA_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST/data\"|" "$NEW_MASTER"
|
||||
sed -i "s|^\(\s*STATE_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST/State_Files\"|" "$NEW_MASTER"
|
||||
echo " TARGET_DIR → $DST ✅"
|
||||
echo " DATA_DIR → $DST/data ✅"
|
||||
echo " STATE_DIR → $DST/State_Files ✅"
|
||||
else
|
||||
error "master.conf not found at $NEW_MASTER"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
warn "DRY RUN — would update TARGET_DIR, DATA_DIR, STATE_DIR in master.conf"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: Update STORAGE_MODE_INTERNAL in host*.conf (new location)
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 5: Update STORAGE_MODE_INTERNAL ━━━"
|
||||
NEW_CONF="$DST/Configurations/${MY_ID,,}.conf"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if [[ -f "$NEW_CONF" ]]; then
|
||||
if grep -q "${MY_ID}_STORAGE_MODE_INTERNAL" "$NEW_CONF"; then
|
||||
sed -i "s|^\(\s*${MY_ID}_STORAGE_MODE_INTERNAL\s*=\s*\).*|\1${NEW_INTERNAL}|" "$NEW_CONF"
|
||||
else
|
||||
sed -i "/# ━━━ Storage mode/a\\ ${MY_ID}_STORAGE_MODE_INTERNAL=${NEW_INTERNAL}" "$NEW_CONF"
|
||||
fi
|
||||
echo " ${MY_ID}_STORAGE_MODE_INTERNAL → $NEW_INTERNAL ✅"
|
||||
else
|
||||
warn "${MY_ID,,}.conf not found at $NEW_CONF — skipping conf update"
|
||||
fi
|
||||
else
|
||||
warn "DRY RUN — would set ${MY_ID}_STORAGE_MODE_INTERNAL=$NEW_INTERNAL"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 6: Rebuild cron (paths must reference new SCRIPTS_DIR)
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 6: Rebuild cron ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
RESULT=$(php -r "
|
||||
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
|
||||
\$_c = @parse_ini_file(PLUGIN_CFG) ?: [];
|
||||
define('SCRIPTS_DIR', \$_c['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
|
||||
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
|
||||
define('DATA_DIR', SCRIPTS_DIR . '/data');
|
||||
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
|
||||
define('LOG_DIR', '/var/log/varaverk');
|
||||
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/confform.php';
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php';
|
||||
\$ok = vv_cron_rebuild(vv_schedule_load());
|
||||
echo \$ok ? 'ok' : 'fail';
|
||||
" 2>/dev/null)
|
||||
if [[ "$RESULT" == "ok" ]]; then
|
||||
echo " Cron rebuilt ✅"
|
||||
else
|
||||
warn "Cron rebuild failed — run Settings → Scheduler → Save to regenerate"
|
||||
fi
|
||||
else
|
||||
warn "DRY RUN — would rebuild cron with new SCRIPTS_DIR paths"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 7: Flash mode — sync Plugin/ to /boot/ so webUI is current
|
||||
if [[ "$TO_MODE" == "flash" && "$DRY_RUN" == false ]]; then
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Step 7: Sync Plugin/ → /boot/ ━━━"
|
||||
if rsync -a --delete "$DST/Plugin/" "/boot/config/plugins/varaverk/Plugin/" 2>/dev/null; then
|
||||
echo " Plugin/ synced to /boot/ ✅"
|
||||
else
|
||||
warn "Plugin/ sync to /boot/ failed — webUI may be stale"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 8: Delete old location
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step $([[ "$TO_MODE" == "flash" ]] && echo 8 || echo 7): Clean up old location ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if [[ "$SRC" == "$INTERNAL_DIR" ]]; then
|
||||
# Migrating internal→flash: keep varaverk.cfg and Plugin/ in /boot/, remove everything else
|
||||
echo " Removing scripts/conf/state from /boot/ (keeping Plugin/ and varaverk.cfg)..."
|
||||
find "$SRC" -mindepth 1 -maxdepth 1 \
|
||||
! -name 'Plugin' \
|
||||
! -name 'varaverk.cfg' \
|
||||
! -name '*.plg' \
|
||||
! -name '*.txz' \
|
||||
-exec rm -rf {} + 2>/dev/null
|
||||
echo " /boot/ cleaned ✅ (Plugin/ and varaverk.cfg preserved)"
|
||||
else
|
||||
# Migrating flash→internal: remove appdata copy entirely
|
||||
echo " Removing $SRC..."
|
||||
rm -rf "$SRC"
|
||||
echo " $SRC removed ✅"
|
||||
fi
|
||||
else
|
||||
if [[ "$SRC" == "$INTERNAL_DIR" ]]; then
|
||||
warn "DRY RUN — would remove scripts/conf/state from /boot/ (keeping Plugin/ and varaverk.cfg)"
|
||||
else
|
||||
warn "DRY RUN — would remove $SRC"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_DONE Migration complete ━━━━━"
|
||||
echo "$ICON_GEAR Mode: $TO_MODE"
|
||||
echo "$ICON_GEAR SCRIPTS_DIR: $DST"
|
||||
if [[ "$TO_MODE" == "flash" ]]; then
|
||||
echo ""
|
||||
warn "IMPORTANT: Varaverk requires the array to be started to function in flash mode."
|
||||
warn "The webUI plugin tab will load normally at all times (Plugin/ stays in /boot/)."
|
||||
fi
|
||||
echo ""
|
||||
echo " Reload the Varaverk plugin tab to apply changes."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ================================= Storage Migration ==========================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Migrates Varaverk between internal NVMe and USB flash storage modes.
|
||||
#
|
||||
# Internal mode: SCRIPTS_DIR = /boot/config/plugins/varaverk
|
||||
# All scripts, conf, state, and git repo live on fast internal storage.
|
||||
# Direct git pull/push. Zero write-wear concern.
|
||||
#
|
||||
# Flash mode: SCRIPTS_DIR = /mnt/user/appdata/Varaverk
|
||||
# All scripts, conf, state, and git repo live in appdata.
|
||||
# Preserves USB flash lifetime. Array must be started for Varaverk to function.
|
||||
# git_pull_execute.sh syncs Plugin/ back to /boot/ after each pull so the
|
||||
# Unraid webUI always serves current PHP files.
|
||||
#
|
||||
# What this script updates:
|
||||
# varaverk.cfg SCRIPTS_DIR
|
||||
# master.conf TARGET_DIR
|
||||
# host*.conf HOST*_STORAGE_MODE_INTERNAL
|
||||
# varaverk.cron rebuilt via PHP (job paths regenerated for new SCRIPTS_DIR)
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# storage_migrate.sh --to=internal
|
||||
# Migrate to /boot/config/plugins/varaverk
|
||||
#
|
||||
# storage_migrate.sh --to=flash
|
||||
# Migrate to /mnt/user/appdata/Varaverk
|
||||
#
|
||||
# storage_migrate.sh --dry-run --to=<mode>
|
||||
# Show what would happen — no changes made
|
||||
#
|
||||
# storage_migrate.sh --status
|
||||
# Show current mode, paths, and boot device info
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../../../load_config.sh"
|
||||
|
||||
parse_args "$@"
|
||||
acquire_lock
|
||||
|
||||
VV_CFG="/boot/config/plugins/varaverk/varaverk.cfg"
|
||||
INTERNAL_DIR="/boot/config/plugins/varaverk"
|
||||
FLASH_DIR="/mnt/user/appdata/Varaverk"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Parse --to= from raw args (parse_args doesn't handle this flag)
|
||||
TO_MODE=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--to=internal) TO_MODE="internal" ;;
|
||||
--to=flash) TO_MODE="flash" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Boot device detection
|
||||
detect_boot_storage() {
|
||||
local boot_part boot_disk transport
|
||||
boot_part=$(findmnt -n -o SOURCE /boot 2>/dev/null)
|
||||
boot_disk=$(lsblk -no pkname "$boot_part" 2>/dev/null)
|
||||
transport=$(lsblk -dno TRAN "/dev/$boot_disk" 2>/dev/null | tr '[:upper:]' '[:lower:]')
|
||||
echo "${transport:-unknown}"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Status
|
||||
if [[ "$SHOW_STATUS" == true ]]; then
|
||||
TRANSPORT=$(detect_boot_storage)
|
||||
DETECTED=$([[ "$TRANSPORT" == "usb" ]] && echo "flash" || echo "internal")
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY STORAGE STATUS ━━━━━"
|
||||
echo "$ICON_GEAR SCRIPTS_DIR: $SCRIPTS_DIR"
|
||||
echo "$ICON_GEAR varaverk.cfg: $VV_CFG"
|
||||
echo "$ICON_HOST Boot device: transport=$TRANSPORT → detected=$DETECTED"
|
||||
echo "$ICON_GEAR Target dirs:"
|
||||
echo " internal: $INTERNAL_DIR"
|
||||
echo " flash: $FLASH_DIR"
|
||||
CONF_MODE=$(grep -m1 "${MY_ID}_STORAGE_MODE_INTERNAL" "$CONF_FILE" 2>/dev/null | cut -d= -f2 | tr -d '"' | tr -d '[:space:]')
|
||||
echo "$ICON_GEAR conf setting: ${MY_ID}_STORAGE_MODE_INTERNAL=${CONF_MODE:-not set}"
|
||||
if [[ "$SCRIPTS_DIR" == "$INTERNAL_DIR" ]]; then
|
||||
echo "$ICON_DONE Current mode: INTERNAL ✅"
|
||||
elif [[ "$SCRIPTS_DIR" == "$FLASH_DIR" ]]; then
|
||||
echo "$ICON_DONE Current mode: FLASH ✅"
|
||||
else
|
||||
echo "$ICON_WARN Current mode: CUSTOM ($SCRIPTS_DIR)"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
if [[ -z "$TO_MODE" ]]; then
|
||||
error "Usage: storage_migrate.sh --to=internal|flash [--dry-run] [--log]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SRC="$SCRIPTS_DIR"
|
||||
DST=$([[ "$TO_MODE" == "internal" ]] && echo "$INTERNAL_DIR" || echo "$FLASH_DIR")
|
||||
NEW_INTERNAL=$([[ "$TO_MODE" == "internal" ]] && echo "true" || echo "false")
|
||||
|
||||
log "$ICON_GEAR Config: to=${TO_MODE} src=${SRC} dst=${DST} dry-run=${DRY_RUN}"
|
||||
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SYNC Storage Migration ━━━━━"
|
||||
echo "$ICON_GEAR From: $SRC"
|
||||
echo "$ICON_GEAR To: $DST"
|
||||
echo "$ICON_GEAR Mode: $TO_MODE"
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
echo ""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pre-flight checks
|
||||
if [[ "$SRC" == "$DST" ]]; then
|
||||
info "Already in $TO_MODE mode — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$TO_MODE" == "flash" ]]; then
|
||||
if ! mountpoint -q /mnt/user 2>/dev/null; then
|
||||
error "Array not started — /mnt/user is not mounted. Start the array before migrating to flash."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SRC/load_config.sh" ]]; then
|
||||
error "Source directory looks invalid: $SRC (load_config.sh not found)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Git push — ensure remote has everything before we touch the local repo
|
||||
echo "━━━ $ICON_SYNC Step 1: Git push ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if [[ -d "$SRC/.git" ]]; then
|
||||
log "Pushing to Gitea before migration..."
|
||||
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT" \
|
||||
git -C "$SRC" push origin main 2>&1 | while IFS= read -r line; do echo " $line"; done; then
|
||||
echo " Git push complete ✅"
|
||||
else
|
||||
warn "Git push failed — continuing (data safe locally, push manually after migration)"
|
||||
fi
|
||||
else
|
||||
warn "No .git directory in $SRC — skipping push"
|
||||
fi
|
||||
else
|
||||
warn "DRY RUN — would push $SRC to Gitea"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Rsync content to destination
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Step 2: Copy files ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
mkdir -p "$DST"
|
||||
echo " rsync: $SRC/ → $DST/"
|
||||
if rsync -av --delete \
|
||||
--exclude='.git' \
|
||||
"$SRC/" "$DST/" 2>&1 | \
|
||||
grep -v "/$" | \
|
||||
while IFS= read -r line; do log "$line"; done; then
|
||||
echo " Files copied ✅"
|
||||
else
|
||||
error "rsync failed — aborting migration"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy .git separately (rsync --exclude='.git' above skipped it)
|
||||
echo " Copying .git..."
|
||||
if cp -a "$SRC/.git" "$DST/.git" 2>/dev/null || \
|
||||
rsync -a "$SRC/.git/" "$DST/.git/" 2>/dev/null; then
|
||||
echo " .git copied ✅"
|
||||
else
|
||||
error ".git copy failed — aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Mark git safe directory
|
||||
git config --global --add safe.directory "$DST" 2>/dev/null
|
||||
else
|
||||
warn "DRY RUN — would rsync $SRC/ → $DST/ (including .git)"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: Update varaverk.cfg
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 3: Update varaverk.cfg ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if grep -q '^SCRIPTS_DIR' "$VV_CFG"; then
|
||||
sed -i "s|^SCRIPTS_DIR=.*|SCRIPTS_DIR=\"$DST\"|" "$VV_CFG"
|
||||
else
|
||||
echo "SCRIPTS_DIR=\"$DST\"" >> "$VV_CFG"
|
||||
fi
|
||||
echo " SCRIPTS_DIR → $DST ✅"
|
||||
else
|
||||
warn "DRY RUN — would set SCRIPTS_DIR=\"$DST\" in $VV_CFG"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Update TARGET_DIR, DATA_DIR, STATE_DIR in master.conf (new location)
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 4: Update master.conf paths ━━━"
|
||||
NEW_MASTER="$DST/Configurations/master.conf"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if [[ -f "$NEW_MASTER" ]]; then
|
||||
sed -i "s|^\(\s*TARGET_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST\"|" "$NEW_MASTER"
|
||||
sed -i "s|^\(\s*DATA_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST/data\"|" "$NEW_MASTER"
|
||||
sed -i "s|^\(\s*STATE_DIR\s*=\s*\)\"[^\"]*\"|\1\"$DST/State_Files\"|" "$NEW_MASTER"
|
||||
echo " TARGET_DIR → $DST ✅"
|
||||
echo " DATA_DIR → $DST/data ✅"
|
||||
echo " STATE_DIR → $DST/State_Files ✅"
|
||||
else
|
||||
error "master.conf not found at $NEW_MASTER"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
warn "DRY RUN — would update TARGET_DIR, DATA_DIR, STATE_DIR in master.conf"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: Update STORAGE_MODE_INTERNAL in host*.conf (new location)
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 5: Update STORAGE_MODE_INTERNAL ━━━"
|
||||
NEW_CONF="$DST/Configurations/${MY_ID,,}.conf"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if [[ -f "$NEW_CONF" ]]; then
|
||||
if grep -q "${MY_ID}_STORAGE_MODE_INTERNAL" "$NEW_CONF"; then
|
||||
sed -i "s|^\(\s*${MY_ID}_STORAGE_MODE_INTERNAL\s*=\s*\).*|\1${NEW_INTERNAL}|" "$NEW_CONF"
|
||||
else
|
||||
sed -i "/# ━━━ Storage mode/a\\ ${MY_ID}_STORAGE_MODE_INTERNAL=${NEW_INTERNAL}" "$NEW_CONF"
|
||||
fi
|
||||
echo " ${MY_ID}_STORAGE_MODE_INTERNAL → $NEW_INTERNAL ✅"
|
||||
else
|
||||
warn "${MY_ID,,}.conf not found at $NEW_CONF — skipping conf update"
|
||||
fi
|
||||
else
|
||||
warn "DRY RUN — would set ${MY_ID}_STORAGE_MODE_INTERNAL=$NEW_INTERNAL"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 6: Rebuild cron (paths must reference new SCRIPTS_DIR)
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step 6: Rebuild cron ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
RESULT=$(php -r "
|
||||
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
|
||||
\$_c = @parse_ini_file(PLUGIN_CFG) ?: [];
|
||||
define('SCRIPTS_DIR', \$_c['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
|
||||
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
|
||||
define('DATA_DIR', SCRIPTS_DIR . '/data');
|
||||
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
|
||||
define('LOG_DIR', '/var/log/varaverk');
|
||||
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/confform.php';
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php';
|
||||
\$ok = vv_cron_rebuild(vv_schedule_load());
|
||||
echo \$ok ? 'ok' : 'fail';
|
||||
" 2>/dev/null)
|
||||
if [[ "$RESULT" == "ok" ]]; then
|
||||
echo " Cron rebuilt ✅"
|
||||
else
|
||||
warn "Cron rebuild failed — run Settings → Scheduler → Save to regenerate"
|
||||
fi
|
||||
else
|
||||
warn "DRY RUN — would rebuild cron with new SCRIPTS_DIR paths"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 7: Flash mode — sync Plugin/ to /boot/ so webUI is current
|
||||
if [[ "$TO_MODE" == "flash" && "$DRY_RUN" == false ]]; then
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Step 7: Sync Plugin/ → /boot/ ━━━"
|
||||
if rsync -a --delete "$DST/Plugin/" "/boot/config/plugins/varaverk/Plugin/" 2>/dev/null; then
|
||||
echo " Plugin/ synced to /boot/ ✅"
|
||||
else
|
||||
warn "Plugin/ sync to /boot/ failed — webUI may be stale"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Step 8: Delete old location
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Step $([[ "$TO_MODE" == "flash" ]] && echo 8 || echo 7): Clean up old location ━━━"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if [[ "$SRC" == "$INTERNAL_DIR" ]]; then
|
||||
# Migrating internal→flash: keep varaverk.cfg and Plugin/ in /boot/, remove everything else
|
||||
echo " Removing scripts/conf/state from /boot/ (keeping Plugin/ and varaverk.cfg)..."
|
||||
find "$SRC" -mindepth 1 -maxdepth 1 \
|
||||
! -name 'Plugin' \
|
||||
! -name 'varaverk.cfg' \
|
||||
! -name '*.plg' \
|
||||
! -name '*.txz' \
|
||||
-exec rm -rf {} + 2>/dev/null
|
||||
echo " /boot/ cleaned ✅ (Plugin/ and varaverk.cfg preserved)"
|
||||
else
|
||||
# Migrating flash→internal: remove appdata copy entirely
|
||||
echo " Removing $SRC..."
|
||||
rm -rf "$SRC"
|
||||
echo " $SRC removed ✅"
|
||||
fi
|
||||
else
|
||||
if [[ "$SRC" == "$INTERNAL_DIR" ]]; then
|
||||
warn "DRY RUN — would remove scripts/conf/state from /boot/ (keeping Plugin/ and varaverk.cfg)"
|
||||
else
|
||||
warn "DRY RUN — would remove $SRC"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_DONE Migration complete ━━━━━"
|
||||
echo "$ICON_GEAR Mode: $TO_MODE"
|
||||
echo "$ICON_GEAR SCRIPTS_DIR: $DST"
|
||||
if [[ "$TO_MODE" == "flash" ]]; then
|
||||
echo ""
|
||||
warn "IMPORTANT: Varaverk requires the array to be started to function in flash mode."
|
||||
warn "The webUI plugin tab will load normally at all times (Plugin/ stays in /boot/)."
|
||||
fi
|
||||
echo ""
|
||||
echo " Reload the Varaverk plugin tab to apply changes."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
// First-run setup wizard.
|
||||
// HOST1 path: blank master.conf → fill hostnames → write master.conf + host1.conf → scheduler.
|
||||
// HOST2 path: state file present → pull master.conf from HOST1 → fill host2.conf → scheduler.
|
||||
|
||||
$detectedHostname = trim(shell_exec('hostname -s') ?: '');
|
||||
|
||||
// Check for setup state file pushed by HOST1
|
||||
$setupState = vv_setup_state_read();
|
||||
$host1FromState = $setupState['host1_hostname'] ?? '';
|
||||
|
||||
// Determine if HOST2 scenario: state file present but master.conf has blank HOST1
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$masterHost1 = trim($_h1m[1] ?? '');
|
||||
|
||||
// Determine local host slot (if master.conf has hostnames, we may already know)
|
||||
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
|
||||
|
||||
// If master.conf has HOST1/HOST2 filled but local host.conf is missing:
|
||||
// This is the "master was pushed, just need the local conf" scenario
|
||||
$myHostId = vv_detect_host(); // may be 'host2' if master.conf was already pushed
|
||||
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
|
||||
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
|
||||
?>
|
||||
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
|
||||
|
||||
<style>
|
||||
#vv-setup {
|
||||
max-width: 560px;
|
||||
margin: 48px auto 0;
|
||||
background: #141414;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
padding: 36px 40px 40px;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
}
|
||||
#vv-setup h1 { margin: 0 0 6px; font-size: 18px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
|
||||
#vv-setup .vv-setup-sub { font-size: 12px; color: #555; margin-bottom: 32px; }
|
||||
#vv-setup .vv-setup-field { margin-bottom: 20px; }
|
||||
#vv-setup label { display: block; font-size: 11px; color: #888; margin-bottom: 6px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
#vv-setup input[type=text] { width: 100%; box-sizing: border-box; background: #0d0d0d; border: 1px solid #333; color: #ddd; padding: 7px 10px; border-radius: 3px; font-family: monospace; font-size: 13px; }
|
||||
#vv-setup input[type=text]:focus { outline: none; border-color: #555; }
|
||||
#vv-setup .vv-setup-hint { font-size: 11px; color: #555; margin-top: 5px; }
|
||||
#vv-setup .vv-setup-role { display: flex; gap: 10px; margin-bottom: 24px; }
|
||||
#vv-setup .vv-setup-role-btn { flex: 1; padding: 10px 0; background: #1a1a1a; border: 1px solid #333; border-radius: 3px; color: #888; font-family: monospace; font-size: 12px; cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
|
||||
#vv-setup .vv-setup-role-btn.active { border-color: #555; color: #ccc; background: #222; }
|
||||
#vv-setup .vv-setup-conditional { display: none; }
|
||||
#vv-setup .vv-setup-conditional.visible { display: block; }
|
||||
#vv-setup hr.vv-setup-divider { border: none; border-top: 1px solid #222; margin: 24px 0; }
|
||||
#vv-setup-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444; color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px; cursor: pointer; letter-spacing: .03em; }
|
||||
#vv-setup-btn:hover { border-color: #666; color: #eee; }
|
||||
#vv-setup-btn:disabled { opacity: .45; cursor: default; }
|
||||
#vv-setup-status { margin-top: 12px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
|
||||
#vv-setup-status.ok { color: #4a8; }
|
||||
#vv-setup-status.err { color: #a44; }
|
||||
.vv-setup-info-box { background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px; padding: 12px 14px; margin-bottom: 24px; font-size: 12px; color: #777; line-height: 1.6; }
|
||||
.vv-setup-info-box strong { color: #aaa; }
|
||||
</style>
|
||||
|
||||
<div id="vv-setup">
|
||||
|
||||
<?php if ($isConfOnlyFlow): ?>
|
||||
<!-- master.conf was pushed by HOST1, just need local host.conf -->
|
||||
<?php $slotLabel = strtoupper($myHostId); ?>
|
||||
<h1>⬡ Varaverk — <?= htmlspecialchars($slotLabel) ?> Setup</h1>
|
||||
<div class="vv-setup-sub">master.conf received from HOST1. Create your local configuration to continue.</div>
|
||||
|
||||
<div class="vv-setup-info-box">
|
||||
<strong>HOST1:</strong> <?= htmlspecialchars($masterHost1) ?><br>
|
||||
<strong>This server:</strong> <?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars($slotLabel) ?><br>
|
||||
<strong>Creating:</strong> <?= htmlspecialchars($myHostId) ?>.conf
|
||||
</div>
|
||||
|
||||
<button id="vv-setup-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
|
||||
<div id="vv-setup-status"></div>
|
||||
|
||||
<script>
|
||||
function vvDoConfOnly() {
|
||||
const btn = document.getElementById('vv-setup-btn');
|
||||
const status = document.getElementById('vv-setup-status');
|
||||
btn.disabled = true; btn.textContent = 'Creating…';
|
||||
const params = new URLSearchParams({
|
||||
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
|
||||
action: 'save',
|
||||
host1: <?= json_encode($masterHost1) ?>,
|
||||
host2: <?= json_encode(trim(preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $_master, $m2) ? $m2[1] : '')) ?>,
|
||||
my_slot: <?= json_encode($myHostId) ?>,
|
||||
my_hostname: <?= json_encode($detectedHostname) ?>,
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = '✓ Created'; status.className = 'ok';
|
||||
vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>));
|
||||
} else {
|
||||
btn.disabled = false; btn.textContent = 'Create <?= htmlspecialchars($myHostId) ?>.conf and continue →';
|
||||
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
|
||||
}
|
||||
}).catch(() => { btn.disabled = false; btn.textContent = 'Create <?= htmlspecialchars($myHostId) ?>.conf and continue →'; status.textContent = '✗ Request failed'; status.className = 'err'; });
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php elseif ($isHost2Flow): ?>
|
||||
<!-- State file present, master.conf blank — HOST2 pull flow -->
|
||||
<h1>⬡ Varaverk — Partner Setup</h1>
|
||||
<div class="vv-setup-sub">HOST1 has been configured. Pull their settings to continue.</div>
|
||||
|
||||
<div class="vv-setup-info-box">
|
||||
<strong>HOST1 detected:</strong> <?= htmlspecialchars($host1FromState) ?><br>
|
||||
This server will pull master.conf from HOST1 via Tailscale + SSH.<br>
|
||||
<span style="color:#555">Requires SSH keys to be exchanged first (Partnership/ssh_setup.sh).</span>
|
||||
</div>
|
||||
|
||||
<div class="vv-setup-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-setup-hint">Must match Settings → Identification exactly</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-setup-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-partner-slot" style="width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #333;color:#ddd;padding:7px 10px;border-radius:3px;font-family:monospace;font-size:13px;">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="vv-setup-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>
|
||||
<div id="vv-setup-status"></div>
|
||||
|
||||
<script>
|
||||
function vvDoPull() {
|
||||
const btn = document.getElementById('vv-setup-btn');
|
||||
const status = document.getElementById('vv-setup-status');
|
||||
const hostname = document.getElementById('vv-hostname').value.trim();
|
||||
const slot = document.getElementById('vv-partner-slot').value;
|
||||
if (!hostname) { status.textContent = '✗ Hostname required'; status.className = 'err'; return; }
|
||||
btn.disabled = true; btn.textContent = 'Pulling…';
|
||||
const params = new URLSearchParams({
|
||||
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
|
||||
action: 'pull',
|
||||
host1_hostname: <?= json_encode($host1FromState) ?>,
|
||||
my_slot: slot,
|
||||
my_hostname: hostname,
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = '✓ Configuration pulled'; status.className = 'ok';
|
||||
vvShowStep2(d.redirect ?? '?tab=scheduler');
|
||||
} else {
|
||||
btn.disabled = false; btn.textContent = 'Pull configuration from HOST1 →';
|
||||
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
|
||||
}
|
||||
}).catch(() => { btn.disabled = false; btn.textContent = 'Pull configuration from HOST1 →'; status.textContent = '✗ Request failed'; status.className = 'err'; });
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Standard first-run: no state file, blank master.conf -->
|
||||
<h1>⬡ Varaverk — First Run</h1>
|
||||
<div class="vv-setup-sub">Set up your server identity before the plugin can start.</div>
|
||||
|
||||
<div class="vv-setup-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" placeholder="unRAID-MyServer" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-setup-hint">Must match Settings → Identification exactly (case-sensitive)</div>
|
||||
</div>
|
||||
|
||||
<hr class="vv-setup-divider">
|
||||
|
||||
<label style="margin-bottom:10px;display:block;">Server role</label>
|
||||
<div class="vv-setup-role">
|
||||
<div class="vv-setup-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
|
||||
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first to be set up</span>
|
||||
</div>
|
||||
<div class="vv-setup-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
|
||||
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining an existing primary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-setup-conditional" id="vv-cond-primary">
|
||||
<div class="vv-setup-field">
|
||||
<label>Partner's hostname <span style="color:#444">(optional — can fill in later)</span></label>
|
||||
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-setup-conditional" id="vv-cond-partner">
|
||||
<div class="vv-setup-field">
|
||||
<label>Primary server's hostname <span style="color:#a44">*required</span></label>
|
||||
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="vv-setup-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-partner-slot" style="width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #333;color:#ddd;padding:7px 10px;border-radius:3px;font-family:monospace;font-size:13px;">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="vv-setup-btn" onclick="vvDoSetup()">Save and continue →</button>
|
||||
<div id="vv-setup-status"></div>
|
||||
|
||||
<script>
|
||||
let vvRole = 'primary';
|
||||
function vvSetRole(role) {
|
||||
vvRole = role;
|
||||
document.getElementById('vv-role-primary').classList.toggle('active', role === 'primary');
|
||||
document.getElementById('vv-role-partner').classList.toggle('active', role === 'partner');
|
||||
document.getElementById('vv-cond-primary').classList.toggle('visible', role === 'primary');
|
||||
document.getElementById('vv-cond-partner').classList.toggle('visible', role === 'partner');
|
||||
}
|
||||
function vvDoSetup() {
|
||||
const hostname = document.getElementById('vv-hostname').value.trim();
|
||||
const status = document.getElementById('vv-setup-status');
|
||||
const btn = document.getElementById('vv-setup-btn');
|
||||
if (!hostname) { status.textContent = '✗ Hostname is required'; status.className = 'err'; return; }
|
||||
let host1 = '', host2 = '', mySlot = 'host1';
|
||||
if (vvRole === 'primary') {
|
||||
host1 = hostname;
|
||||
host2 = document.getElementById('vv-partner-hostname').value.trim();
|
||||
mySlot = 'host1';
|
||||
} else {
|
||||
const primary = document.getElementById('vv-primary-hostname').value.trim();
|
||||
if (!primary) { status.textContent = '✗ Primary hostname required'; status.className = 'err'; return; }
|
||||
mySlot = document.getElementById('vv-partner-slot').value;
|
||||
host1 = primary;
|
||||
if (mySlot === 'host2') host2 = hostname;
|
||||
}
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
const params = new URLSearchParams({
|
||||
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
|
||||
action: 'save', host1, host2, my_slot: mySlot, my_hostname: hostname,
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = '✓ Saved'; status.className = 'ok';
|
||||
vvShowStep2(d.redirect ?? '?tab=scheduler&vv_setup=master.conf');
|
||||
} else {
|
||||
btn.disabled = false; btn.textContent = 'Save and continue →';
|
||||
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
|
||||
}
|
||||
}).catch(() => { btn.disabled = false; btn.textContent = 'Save and continue →'; status.textContent = '✗ Request failed'; status.className = 'err'; });
|
||||
}
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Step 2: API key — shown after any wizard flow completes -->
|
||||
<div id="vv-setup-step2" style="display:none;">
|
||||
<hr class="vv-setup-divider">
|
||||
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Step 2 of 2 — Unraid API Key</div>
|
||||
<div class="vv-setup-info-box">
|
||||
Varaverk uses the local Unraid API to display live stats on the Monitor tab.
|
||||
Creates a <strong>Varaverk</strong> key via <code style="color:#555;">unraid-api</code> and writes it to your host conf.
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;">
|
||||
<button id="vv-key-btn2" onclick="vvCreateApiKeyWizard(this)"
|
||||
style="flex:1;padding:10px 0;background:#1a3a1a;border:1px solid #2e6b2e;color:#6fcf97;
|
||||
font-family:monospace;font-size:13px;border-radius:3px;cursor:pointer;">
|
||||
Create API Key
|
||||
</button>
|
||||
<a id="vv-skip-link" href="#" onclick="vvWizardContinue(event)"
|
||||
style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
|
||||
</div>
|
||||
<div id="vv-key-status2" style="margin-top:8px;font-size:12px;min-height:16px;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _vvWizardNext = '';
|
||||
function vvShowStep2(redirect) {
|
||||
_vvWizardNext = redirect;
|
||||
document.getElementById('vv-setup-step2').style.display = 'block';
|
||||
}
|
||||
function vvWizardContinue(e) {
|
||||
if (e) e.preventDefault();
|
||||
window.location.href = _vvWizardNext || '?tab=scheduler';
|
||||
}
|
||||
function vvCreateApiKeyWizard(btn) {
|
||||
const status = document.getElementById('vv-key-status2');
|
||||
btn.disabled = true; btn.textContent = '⟳ Creating…';
|
||||
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = '✓ Key created — ' + d.key_preview; status.style.color = '#4a8';
|
||||
btn.textContent = 'Continue →'; btn.disabled = false;
|
||||
btn.onclick = vvWizardContinue;
|
||||
const skip = document.getElementById('vv-skip-link');
|
||||
if (skip) skip.style.display = 'none';
|
||||
} else {
|
||||
status.textContent = '✗ ' + (d.error ?? 'Failed'); status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
status.textContent = '✗ ' + e; status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,463 @@
|
||||
<?php
|
||||
// First-run setup wizard.
|
||||
// Step 1: Auto-detect environment + server identity form.
|
||||
// Step 2: Auto-populate + guide + checklist.
|
||||
// Handles three scenarios:
|
||||
// standard — blank master.conf, this is HOST1
|
||||
// host2-pull — state file pushed by HOST1, pull master.conf via SSH
|
||||
// conf-only — master.conf already here, just create local host.conf
|
||||
|
||||
$detectedHostname = vv_get_hostname();
|
||||
$setupState = vv_setup_state_read();
|
||||
$host1FromState = $setupState['host1_hostname'] ?? '';
|
||||
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$masterHost1 = trim($_h1m[1] ?? '');
|
||||
|
||||
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
|
||||
$myHostId = vv_detect_host();
|
||||
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
|
||||
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
|
||||
?>
|
||||
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
|
||||
<style>
|
||||
#vv-setup {
|
||||
max-width: 580px; margin: 40px auto 0;
|
||||
background: #141414; border: 1px solid #2a2a2a;
|
||||
border-radius: 6px; padding: 36px 40px 40px;
|
||||
font-family: monospace; color: #ccc;
|
||||
}
|
||||
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
|
||||
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
|
||||
.vv-field { margin-bottom: 18px; }
|
||||
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.vv-field input[type=text],
|
||||
.vv-field select {
|
||||
width: 100%; box-sizing: border-box; background: #0d0d0d;
|
||||
border: 1px solid #333; color: #ddd; padding: 7px 10px;
|
||||
border-radius: 3px; font-family: monospace; font-size: 13px;
|
||||
}
|
||||
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
|
||||
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
|
||||
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
|
||||
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
|
||||
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
|
||||
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
|
||||
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
|
||||
.vv-cond { display: none; }
|
||||
.vv-cond.show { display: block; }
|
||||
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
|
||||
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
|
||||
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
|
||||
cursor: pointer; letter-spacing: .03em; }
|
||||
.vv-btn:hover { border-color: #666; color: #eee; }
|
||||
.vv-btn:disabled { opacity: .4; cursor: default; }
|
||||
.vv-btn-sm { padding: 6px 14px; font-size: 12px; width: auto; }
|
||||
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
|
||||
#vv-status.ok { color: #4a8; }
|
||||
#vv-status.err { color: #a44; }
|
||||
|
||||
/* Detection banner */
|
||||
#vv-detect-banner {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8;
|
||||
color: #666;
|
||||
}
|
||||
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
|
||||
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
|
||||
#vv-detect-banner .vv-det-val { color: #999; }
|
||||
#vv-detect-banner .loading { color: #444; font-style: italic; }
|
||||
|
||||
/* Step 2: guide + checklist */
|
||||
#vv-step2 { display: none; }
|
||||
.vv-guide {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
|
||||
}
|
||||
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
|
||||
.vv-guide li { margin-bottom: 3px; }
|
||||
.vv-guide a { color: #556; }
|
||||
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
|
||||
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-cl-item:last-child { border-bottom: none; }
|
||||
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
|
||||
.vv-cl-body { flex: 1; }
|
||||
.vv-cl-label { color: #bbb; }
|
||||
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
|
||||
.vv-cl-act { margin-top: 5px; }
|
||||
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
|
||||
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
|
||||
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
|
||||
</style>
|
||||
|
||||
<div id="vv-setup">
|
||||
|
||||
<h1>⬡ Varaverk <?php
|
||||
if ($isConfOnlyFlow) echo '— ' . strtoupper($myHostId) . ' Setup';
|
||||
elseif ($isHost2Flow) echo '— Partner Setup';
|
||||
else echo '— First Run';
|
||||
?></h1>
|
||||
<div class="vv-sub"><?php
|
||||
if ($isConfOnlyFlow) echo 'master.conf received. Create your local configuration to continue.';
|
||||
elseif ($isHost2Flow) echo 'HOST1 is configured. Pull their settings to connect.';
|
||||
else echo 'Set up your server before the plugin can start.';
|
||||
?></div>
|
||||
|
||||
<!-- ── Step 1: Detection + identity form ──────────────────────────────────── -->
|
||||
<div id="vv-step1">
|
||||
|
||||
<!-- Detection banner — populated by JS on load -->
|
||||
<div id="vv-detect-banner">
|
||||
<div class="loading">Detecting environment…</div>
|
||||
</div>
|
||||
|
||||
<?php if ($isConfOnlyFlow): ?>
|
||||
<!-- master.conf already here, just create the local conf -->
|
||||
<div style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:11px 14px;margin-bottom:18px;font-size:12px;color:#666;line-height:1.8;">
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">HOST1:</span> <span style="color:#999;"><?= htmlspecialchars($masterHost1) ?></span></div>
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">This server:</span> <span style="color:#999;"><?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?></span></div>
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">Creating:</span> <span style="color:#999;"><?= htmlspecialchars($myHostId) ?>.conf</span></div>
|
||||
</div>
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
|
||||
|
||||
<?php elseif ($isHost2Flow): ?>
|
||||
<!-- HOST2 pull flow -->
|
||||
<div class="vv-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-hint">Must match Unraid Settings → Identification exactly</div>
|
||||
</div>
|
||||
<div class="vv-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-slot">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#555;margin-bottom:16px;">
|
||||
HOST1 detected: <span style="color:#888;"><?= htmlspecialchars($host1FromState) ?></span><br>
|
||||
Requires SSH keys to be exchanged — run <code>Partnership/ssh_setup.sh</code> first if keys aren't set up.
|
||||
</div>
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Standard first-run: HOST1 -->
|
||||
<div class="vv-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
|
||||
</div>
|
||||
|
||||
<hr class="vv-hr">
|
||||
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
|
||||
<div class="vv-role-row">
|
||||
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
|
||||
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
|
||||
</div>
|
||||
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
|
||||
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-primary">
|
||||
<div class="vv-field">
|
||||
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
|
||||
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-partner">
|
||||
<div class="vv-field">
|
||||
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
|
||||
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="vv-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-partner-slot">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="vv-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
|
||||
<div id="vv-step2">
|
||||
<hr class="vv-hr">
|
||||
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
|
||||
|
||||
<!-- Auto-populate result -->
|
||||
<div id="vv-populate-block">
|
||||
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">
|
||||
⟳ Running auto-populate…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-start guide -->
|
||||
<div class="vv-guide">
|
||||
<strong style="color:#888;">Quick start</strong>
|
||||
<ol>
|
||||
<li>Create your Unraid API key below — needed for live monitor stats</li>
|
||||
<li>Open <strong>Scheduler → Edit host.conf</strong> and fill in your credentials<br>
|
||||
<span style="color:#444;">(Emby key, share paths, Discord webhook)</span></li>
|
||||
<li>If partnering: run <code>Partnership/partnership_onboard.sh</code> once both servers are ready</li>
|
||||
<li>Arr keys, container names, and media paths auto-populate on next array start</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- API key -->
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
|
||||
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
|
||||
Create API Key
|
||||
</button>
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
|
||||
</div>
|
||||
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
|
||||
|
||||
<!-- Checklist -->
|
||||
<hr class="vv-hr">
|
||||
<div class="vv-cl-title">Setup checklist</div>
|
||||
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
|
||||
|
||||
<div style="margin-top:18px;text-align:right;">
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">
|
||||
Go to Scheduler →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _vvRedirect = '?tab=scheduler';
|
||||
|
||||
// ── Detection banner ──────────────────────────────────────────────────────────
|
||||
(function() {
|
||||
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const b = document.getElementById('vv-detect-banner');
|
||||
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
|
||||
const modeLabel = d.mode === 'internal'
|
||||
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
|
||||
: '<span style="color:#a84">flash mode (USB boot)</span>';
|
||||
b.innerHTML =
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver || '') + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
|
||||
// Pre-fill hostname if blank
|
||||
const hf = document.getElementById('vv-hostname');
|
||||
if (hf && !hf.value.trim()) hf.value = d.hostname;
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('vv-detect-banner').innerHTML =
|
||||
'<span style="color:#444">Detection unavailable</span>';
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Role toggle (standard flow only) ─────────────────────────────────────────
|
||||
let vvRole = 'primary';
|
||||
function vvSetRole(role) {
|
||||
vvRole = role;
|
||||
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
|
||||
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
|
||||
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
|
||||
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
|
||||
}
|
||||
|
||||
// ── Status helper ─────────────────────────────────────────────────────────────
|
||||
function vvSetStatus(msg, cls) {
|
||||
const s = document.getElementById('vv-status');
|
||||
s.textContent = msg; s.className = cls || '';
|
||||
}
|
||||
|
||||
function vvSetBtn(text, disabled) {
|
||||
const b = document.getElementById('vv-main-btn');
|
||||
if (b) { b.textContent = text; b.disabled = disabled; }
|
||||
}
|
||||
|
||||
// ── Advance to step 2 ─────────────────────────────────────────────────────────
|
||||
function vvShowStep2(redirect) {
|
||||
_vvRedirect = redirect || '?tab=scheduler';
|
||||
document.getElementById('vv-step1').style.display = 'none';
|
||||
document.getElementById('vv-step2').style.display = 'block';
|
||||
vvRunPopulate();
|
||||
vvLoadChecklist();
|
||||
}
|
||||
|
||||
// ── Populate ──────────────────────────────────────────────────────────────────
|
||||
function vvRunPopulate() {
|
||||
const el = document.getElementById('vv-populate-status');
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
|
||||
el.textContent = found.length
|
||||
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
|
||||
: '✓ Auto-populate ran — arr keys will fill once services are running';
|
||||
el.style.color = '#4a8';
|
||||
} else {
|
||||
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
|
||||
el.style.color = '#555';
|
||||
}
|
||||
vvLoadChecklist();
|
||||
}).catch(() => {
|
||||
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
|
||||
el.style.color = '#555';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Checklist ─────────────────────────────────────────────────────────────────
|
||||
const vvActionLabels = {
|
||||
create_key: 'Create API key',
|
||||
ssh_setup: 'SSH guide →',
|
||||
run_populate: 'Run now',
|
||||
onboard: 'Partnership tab →',
|
||||
};
|
||||
const vvActionHref = {
|
||||
ssh_setup: '?tab=partnership',
|
||||
onboard: '?tab=partnership',
|
||||
};
|
||||
|
||||
function vvLoadChecklist() {
|
||||
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
const el = document.getElementById('vv-checklist');
|
||||
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
|
||||
el.innerHTML = d.items.map(item => {
|
||||
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
|
||||
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
|
||||
let act = '';
|
||||
if (item.action) {
|
||||
const lbl = vvActionLabels[item.action] || item.action;
|
||||
const href = vvActionHref[item.action];
|
||||
if (href) {
|
||||
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
|
||||
} else if (item.action === 'create_key') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
|
||||
} else if (item.action === 'run_populate') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
|
||||
}
|
||||
}
|
||||
return `<div class="vv-cl-item">
|
||||
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
|
||||
<div class="vv-cl-body">
|
||||
<div class="vv-cl-label">${item.label}</div>
|
||||
<div class="vv-cl-detail">${item.detail || ''}</div>
|
||||
${act}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function vvRunPopulateBtn(btn) {
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
|
||||
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
|
||||
}
|
||||
|
||||
// ── API key ───────────────────────────────────────────────────────────────────
|
||||
function vvCreateKey(btn) {
|
||||
const status = document.getElementById('vv-key-status');
|
||||
btn.disabled = true; btn.textContent = '⟳ Creating…';
|
||||
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = '✓ Key created — ' + d.key_preview;
|
||||
status.style.color = '#4a8';
|
||||
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
|
||||
vvLoadChecklist();
|
||||
} else {
|
||||
status.textContent = '✗ ' + (d.error || 'Failed');
|
||||
status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
}
|
||||
}).catch(e => {
|
||||
status.textContent = '✗ ' + e; status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
function vvGoScheduler(e) {
|
||||
if (e) e.preventDefault();
|
||||
window.location.href = _vvRedirect || '?tab=scheduler';
|
||||
}
|
||||
|
||||
// ── Action handlers ───────────────────────────────────────────────────────────
|
||||
function vvDoSave() {
|
||||
const hostname = document.getElementById('vv-hostname')?.value.trim();
|
||||
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
|
||||
let host1 = '', host2 = '', mySlot = 'host1';
|
||||
if (vvRole === 'primary') {
|
||||
host1 = hostname;
|
||||
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
|
||||
mySlot = 'host1';
|
||||
} else {
|
||||
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
|
||||
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
|
||||
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
|
||||
host1 = primary;
|
||||
if (mySlot === 'host2') host2 = hostname;
|
||||
}
|
||||
vvSetBtn('Saving…', true);
|
||||
const params = new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler'); }
|
||||
else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
|
||||
function vvDoPull() {
|
||||
const hostname = document.getElementById('vv-hostname')?.value.trim();
|
||||
const slot = document.getElementById('vv-slot')?.value || 'host2';
|
||||
if (!hostname) { vvSetStatus('✗ Hostname required', 'err'); return; }
|
||||
vvSetBtn('Pulling…', true);
|
||||
const params = new URLSearchParams({
|
||||
action:'pull', host1_hostname:<?= json_encode($host1FromState) ?>,
|
||||
my_slot:slot, my_hostname:hostname
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler'); }
|
||||
else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
|
||||
function vvDoConfOnly() {
|
||||
vvSetBtn('Creating…', true);
|
||||
const params = new URLSearchParams({
|
||||
action:'save',
|
||||
host1: <?= json_encode($masterHost1) ?>,
|
||||
host2: <?= json_encode(trim(preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $_master, $_m2) ? $_m2[1] : '')) ?>,
|
||||
my_slot: <?= json_encode($myHostId) ?>,
|
||||
my_hostname: <?= json_encode($detectedHostname) ?>,
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>)); }
|
||||
else { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,467 @@
|
||||
<?php
|
||||
// First-run setup wizard.
|
||||
// Step 1: Auto-detect environment + server identity form.
|
||||
// Step 2: Auto-populate + guide + checklist.
|
||||
// Handles three scenarios:
|
||||
// standard — blank master.conf, this is HOST1
|
||||
// host2-pull — state file pushed by HOST1, pull master.conf via SSH
|
||||
// conf-only — master.conf already here, just create local host.conf
|
||||
|
||||
$detectedHostname = vv_get_hostname();
|
||||
$setupState = vv_setup_state_read();
|
||||
$host1FromState = $setupState['host1_hostname'] ?? '';
|
||||
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$masterHost1 = trim($_h1m[1] ?? '');
|
||||
|
||||
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
|
||||
$myHostId = vv_detect_host();
|
||||
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
|
||||
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
|
||||
?>
|
||||
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
|
||||
<style>
|
||||
#vv-setup {
|
||||
max-width: 580px; margin: 40px auto 0;
|
||||
background: #141414; border: 1px solid #2a2a2a;
|
||||
border-radius: 6px; padding: 36px 40px 40px;
|
||||
font-family: monospace; color: #ccc;
|
||||
}
|
||||
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
|
||||
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
|
||||
.vv-field { margin-bottom: 18px; }
|
||||
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.vv-field input[type=text],
|
||||
.vv-field select {
|
||||
width: 100%; box-sizing: border-box; background: #0d0d0d;
|
||||
border: 1px solid #333; color: #ddd; padding: 7px 10px;
|
||||
border-radius: 3px; font-family: monospace; font-size: 13px;
|
||||
}
|
||||
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
|
||||
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
|
||||
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
|
||||
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
|
||||
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
|
||||
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
|
||||
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
|
||||
.vv-cond { display: none; }
|
||||
.vv-cond.show { display: block; }
|
||||
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
|
||||
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
|
||||
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
|
||||
cursor: pointer; letter-spacing: .03em; }
|
||||
.vv-btn:hover { border-color: #666; color: #eee; }
|
||||
.vv-btn:disabled { opacity: .4; cursor: default; }
|
||||
.vv-btn-sm { padding: 6px 14px; font-size: 12px; width: auto; }
|
||||
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
|
||||
#vv-status.ok { color: #4a8; }
|
||||
#vv-status.err { color: #a44; }
|
||||
|
||||
/* Detection banner */
|
||||
#vv-detect-banner {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8;
|
||||
color: #666;
|
||||
}
|
||||
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
|
||||
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
|
||||
#vv-detect-banner .vv-det-val { color: #999; }
|
||||
#vv-detect-banner .loading { color: #444; font-style: italic; }
|
||||
|
||||
/* Step 2: guide + checklist */
|
||||
#vv-step2 { display: none; }
|
||||
.vv-guide {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
|
||||
}
|
||||
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
|
||||
.vv-guide li { margin-bottom: 3px; }
|
||||
.vv-guide a { color: #556; }
|
||||
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
|
||||
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-cl-item:last-child { border-bottom: none; }
|
||||
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
|
||||
.vv-cl-body { flex: 1; }
|
||||
.vv-cl-label { color: #bbb; }
|
||||
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
|
||||
.vv-cl-act { margin-top: 5px; }
|
||||
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
|
||||
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
|
||||
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
|
||||
</style>
|
||||
|
||||
<div id="vv-setup">
|
||||
|
||||
<h1>⬡ Varaverk <?php
|
||||
if ($isConfOnlyFlow) echo '— ' . strtoupper($myHostId) . ' Setup';
|
||||
elseif ($isHost2Flow) echo '— Partner Setup';
|
||||
else echo '— First Run';
|
||||
?></h1>
|
||||
<div class="vv-sub"><?php
|
||||
if ($isConfOnlyFlow) echo 'master.conf received. Create your local configuration to continue.';
|
||||
elseif ($isHost2Flow) echo 'HOST1 is configured. Pull their settings to connect.';
|
||||
else echo 'Set up your server before the plugin can start.';
|
||||
?></div>
|
||||
|
||||
<!-- ── Step 1: Detection + identity form ──────────────────────────────────── -->
|
||||
<div id="vv-step1">
|
||||
|
||||
<!-- Detection banner — populated by JS on load -->
|
||||
<div id="vv-detect-banner">
|
||||
<div class="loading">Detecting environment…</div>
|
||||
</div>
|
||||
|
||||
<?php if ($isConfOnlyFlow): ?>
|
||||
<!-- master.conf already here, just create the local conf -->
|
||||
<div style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:11px 14px;margin-bottom:18px;font-size:12px;color:#666;line-height:1.8;">
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">HOST1:</span> <span style="color:#999;"><?= htmlspecialchars($masterHost1) ?></span></div>
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">This server:</span> <span style="color:#999;"><?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?></span></div>
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">Creating:</span> <span style="color:#999;"><?= htmlspecialchars($myHostId) ?>.conf</span></div>
|
||||
</div>
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
|
||||
|
||||
<?php elseif ($isHost2Flow): ?>
|
||||
<!-- HOST2 pull flow -->
|
||||
<div class="vv-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-hint">Must match Unraid Settings → Identification exactly</div>
|
||||
</div>
|
||||
<div class="vv-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-slot">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#555;margin-bottom:16px;">
|
||||
HOST1 detected: <span style="color:#888;"><?= htmlspecialchars($host1FromState) ?></span><br>
|
||||
Requires SSH keys to be exchanged — run <code>Partnership/ssh_setup.sh</code> first if keys aren't set up.
|
||||
</div>
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Standard first-run: HOST1 -->
|
||||
<div class="vv-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
|
||||
</div>
|
||||
|
||||
<hr class="vv-hr">
|
||||
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
|
||||
<div class="vv-role-row">
|
||||
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
|
||||
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
|
||||
</div>
|
||||
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
|
||||
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-primary">
|
||||
<div class="vv-field">
|
||||
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
|
||||
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-partner">
|
||||
<div class="vv-field">
|
||||
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
|
||||
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="vv-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-partner-slot">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="vv-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
|
||||
<div id="vv-step2">
|
||||
<hr class="vv-hr">
|
||||
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
|
||||
|
||||
<!-- Auto-populate result -->
|
||||
<div id="vv-populate-block">
|
||||
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">
|
||||
⟳ Running auto-populate…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-start guide -->
|
||||
<div class="vv-guide">
|
||||
<strong style="color:#888;">Quick start</strong>
|
||||
<ol>
|
||||
<li>Create your Unraid API key below — needed for live monitor stats</li>
|
||||
<li>Open <strong>Scheduler → Edit host.conf</strong> — only three things need manual entry:<br>
|
||||
<span style="color:#444;">
|
||||
<code>EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
|
||||
<code>DISCORD_WEBHOOK</code> — for notifications (optional)<br>
|
||||
<code>DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
|
||||
Everything else was auto-populated or has working defaults
|
||||
</span></li>
|
||||
<li>If partnering: run <code>Partnership/partnership_onboard.sh</code> once both servers are ready</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- API key -->
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
|
||||
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
|
||||
Create API Key
|
||||
</button>
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
|
||||
</div>
|
||||
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
|
||||
|
||||
<!-- Checklist -->
|
||||
<hr class="vv-hr">
|
||||
<div class="vv-cl-title">Setup checklist</div>
|
||||
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
|
||||
|
||||
<div style="margin-top:18px;text-align:right;">
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">
|
||||
Go to Scheduler →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _vvRedirect = '?tab=scheduler';
|
||||
|
||||
// ── Detection banner ──────────────────────────────────────────────────────────
|
||||
(function() {
|
||||
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const b = document.getElementById('vv-detect-banner');
|
||||
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
|
||||
const modeLabel = d.mode === 'internal'
|
||||
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
|
||||
: '<span style="color:#a84">flash mode (USB boot)</span>';
|
||||
b.innerHTML =
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver || '') + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
|
||||
// Pre-fill hostname if blank
|
||||
const hf = document.getElementById('vv-hostname');
|
||||
if (hf && !hf.value.trim()) hf.value = d.hostname;
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('vv-detect-banner').innerHTML =
|
||||
'<span style="color:#444">Detection unavailable</span>';
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Role toggle (standard flow only) ─────────────────────────────────────────
|
||||
let vvRole = 'primary';
|
||||
function vvSetRole(role) {
|
||||
vvRole = role;
|
||||
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
|
||||
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
|
||||
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
|
||||
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
|
||||
}
|
||||
|
||||
// ── Status helper ─────────────────────────────────────────────────────────────
|
||||
function vvSetStatus(msg, cls) {
|
||||
const s = document.getElementById('vv-status');
|
||||
s.textContent = msg; s.className = cls || '';
|
||||
}
|
||||
|
||||
function vvSetBtn(text, disabled) {
|
||||
const b = document.getElementById('vv-main-btn');
|
||||
if (b) { b.textContent = text; b.disabled = disabled; }
|
||||
}
|
||||
|
||||
// ── Advance to step 2 ─────────────────────────────────────────────────────────
|
||||
function vvShowStep2(redirect) {
|
||||
_vvRedirect = redirect || '?tab=scheduler';
|
||||
document.getElementById('vv-step1').style.display = 'none';
|
||||
document.getElementById('vv-step2').style.display = 'block';
|
||||
vvRunPopulate();
|
||||
vvLoadChecklist();
|
||||
}
|
||||
|
||||
// ── Populate ──────────────────────────────────────────────────────────────────
|
||||
function vvRunPopulate() {
|
||||
const el = document.getElementById('vv-populate-status');
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
|
||||
el.textContent = found.length
|
||||
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
|
||||
: '✓ Auto-populate ran — arr keys will fill once services are running';
|
||||
el.style.color = '#4a8';
|
||||
} else {
|
||||
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
|
||||
el.style.color = '#555';
|
||||
}
|
||||
vvLoadChecklist();
|
||||
}).catch(() => {
|
||||
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
|
||||
el.style.color = '#555';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Checklist ─────────────────────────────────────────────────────────────────
|
||||
const vvActionLabels = {
|
||||
create_key: 'Create API key',
|
||||
ssh_setup: 'SSH guide →',
|
||||
run_populate: 'Run now',
|
||||
onboard: 'Partnership tab →',
|
||||
};
|
||||
const vvActionHref = {
|
||||
ssh_setup: '?tab=partnership',
|
||||
onboard: '?tab=partnership',
|
||||
};
|
||||
|
||||
function vvLoadChecklist() {
|
||||
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
const el = document.getElementById('vv-checklist');
|
||||
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
|
||||
el.innerHTML = d.items.map(item => {
|
||||
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
|
||||
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
|
||||
let act = '';
|
||||
if (item.action) {
|
||||
const lbl = vvActionLabels[item.action] || item.action;
|
||||
const href = vvActionHref[item.action];
|
||||
if (href) {
|
||||
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
|
||||
} else if (item.action === 'create_key') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
|
||||
} else if (item.action === 'run_populate') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
|
||||
}
|
||||
}
|
||||
return `<div class="vv-cl-item">
|
||||
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
|
||||
<div class="vv-cl-body">
|
||||
<div class="vv-cl-label">${item.label}</div>
|
||||
<div class="vv-cl-detail">${item.detail || ''}</div>
|
||||
${act}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function vvRunPopulateBtn(btn) {
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
|
||||
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
|
||||
}
|
||||
|
||||
// ── API key ───────────────────────────────────────────────────────────────────
|
||||
function vvCreateKey(btn) {
|
||||
const status = document.getElementById('vv-key-status');
|
||||
btn.disabled = true; btn.textContent = '⟳ Creating…';
|
||||
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = '✓ Key created — ' + d.key_preview;
|
||||
status.style.color = '#4a8';
|
||||
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
|
||||
vvLoadChecklist();
|
||||
} else {
|
||||
status.textContent = '✗ ' + (d.error || 'Failed');
|
||||
status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
}
|
||||
}).catch(e => {
|
||||
status.textContent = '✗ ' + e; status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
function vvGoScheduler(e) {
|
||||
if (e) e.preventDefault();
|
||||
window.location.href = _vvRedirect || '?tab=scheduler';
|
||||
}
|
||||
|
||||
// ── Action handlers ───────────────────────────────────────────────────────────
|
||||
function vvDoSave() {
|
||||
const hostname = document.getElementById('vv-hostname')?.value.trim();
|
||||
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
|
||||
let host1 = '', host2 = '', mySlot = 'host1';
|
||||
if (vvRole === 'primary') {
|
||||
host1 = hostname;
|
||||
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
|
||||
mySlot = 'host1';
|
||||
} else {
|
||||
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
|
||||
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
|
||||
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
|
||||
host1 = primary;
|
||||
if (mySlot === 'host2') host2 = hostname;
|
||||
}
|
||||
vvSetBtn('Saving…', true);
|
||||
const params = new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler'); }
|
||||
else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
|
||||
function vvDoPull() {
|
||||
const hostname = document.getElementById('vv-hostname')?.value.trim();
|
||||
const slot = document.getElementById('vv-slot')?.value || 'host2';
|
||||
if (!hostname) { vvSetStatus('✗ Hostname required', 'err'); return; }
|
||||
vvSetBtn('Pulling…', true);
|
||||
const params = new URLSearchParams({
|
||||
action:'pull', host1_hostname:<?= json_encode($host1FromState) ?>,
|
||||
my_slot:slot, my_hostname:hostname
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler'); }
|
||||
else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
|
||||
function vvDoConfOnly() {
|
||||
vvSetBtn('Creating…', true);
|
||||
const params = new URLSearchParams({
|
||||
action:'save',
|
||||
host1: <?= json_encode($masterHost1) ?>,
|
||||
host2: <?= json_encode(trim(preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $_master, $_m2) ? $_m2[1] : '')) ?>,
|
||||
my_slot: <?= json_encode($myHostId) ?>,
|
||||
my_hostname: <?= json_encode($detectedHostname) ?>,
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>)); }
|
||||
else { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,473 @@
|
||||
<?php
|
||||
// First-run setup wizard.
|
||||
// Step 1: Auto-detect environment + server identity form.
|
||||
// Step 2: Auto-populate + guide + checklist.
|
||||
// Handles three scenarios:
|
||||
// standard — blank master.conf, this is HOST1
|
||||
// host2-pull — state file pushed by HOST1, pull master.conf via SSH
|
||||
// conf-only — master.conf already here, just create local host.conf
|
||||
|
||||
$detectedHostname = vv_get_hostname();
|
||||
$setupState = vv_setup_state_read();
|
||||
$host1FromState = $setupState['host1_hostname'] ?? '';
|
||||
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$masterHost1 = trim($_h1m[1] ?? '');
|
||||
|
||||
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
|
||||
$myHostId = vv_detect_host();
|
||||
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
|
||||
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
|
||||
?>
|
||||
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
|
||||
<style>
|
||||
#vv-setup {
|
||||
max-width: 580px; margin: 40px auto 0;
|
||||
background: #141414; border: 1px solid #2a2a2a;
|
||||
border-radius: 6px; padding: 36px 40px 40px;
|
||||
font-family: monospace; color: #ccc;
|
||||
}
|
||||
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
|
||||
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
|
||||
.vv-field { margin-bottom: 18px; }
|
||||
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.vv-field input[type=text],
|
||||
.vv-field select {
|
||||
width: 100%; box-sizing: border-box; background: #0d0d0d;
|
||||
border: 1px solid #333; color: #ddd; padding: 7px 10px;
|
||||
border-radius: 3px; font-family: monospace; font-size: 13px;
|
||||
}
|
||||
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
|
||||
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
|
||||
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
|
||||
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
|
||||
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
|
||||
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
|
||||
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
|
||||
.vv-cond { display: none; }
|
||||
.vv-cond.show { display: block; }
|
||||
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
|
||||
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
|
||||
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
|
||||
cursor: pointer; letter-spacing: .03em; }
|
||||
.vv-btn:hover { border-color: #666; color: #eee; }
|
||||
.vv-btn:disabled { opacity: .4; cursor: default; }
|
||||
.vv-btn-sm { padding: 6px 14px; font-size: 12px; width: auto; }
|
||||
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
|
||||
#vv-status.ok { color: #4a8; }
|
||||
#vv-status.err { color: #a44; }
|
||||
|
||||
/* Detection banner */
|
||||
#vv-detect-banner {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8;
|
||||
color: #666;
|
||||
}
|
||||
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
|
||||
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
|
||||
#vv-detect-banner .vv-det-val { color: #999; }
|
||||
#vv-detect-banner .loading { color: #444; font-style: italic; }
|
||||
|
||||
/* Step 2: guide + checklist */
|
||||
#vv-step2 { display: none; }
|
||||
.vv-guide {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
|
||||
}
|
||||
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
|
||||
.vv-guide li { margin-bottom: 3px; }
|
||||
.vv-guide a { color: #556; }
|
||||
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
|
||||
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-cl-item:last-child { border-bottom: none; }
|
||||
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
|
||||
.vv-cl-body { flex: 1; }
|
||||
.vv-cl-label { color: #bbb; }
|
||||
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
|
||||
.vv-cl-act { margin-top: 5px; }
|
||||
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
|
||||
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
|
||||
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
|
||||
</style>
|
||||
|
||||
<div id="vv-setup">
|
||||
|
||||
<h1>⬡ Varaverk <?php
|
||||
if ($isConfOnlyFlow) echo '— ' . strtoupper($myHostId) . ' Setup';
|
||||
elseif ($isHost2Flow) echo '— Partner Setup';
|
||||
else echo '— First Run';
|
||||
?></h1>
|
||||
<div class="vv-sub"><?php
|
||||
if ($isConfOnlyFlow) echo 'master.conf received. Create your local configuration to continue.';
|
||||
elseif ($isHost2Flow) echo 'HOST1 is configured. Pull their settings to connect.';
|
||||
else echo 'Set up your server before the plugin can start.';
|
||||
?></div>
|
||||
|
||||
<!-- ── Step 1: Detection + identity form ──────────────────────────────────── -->
|
||||
<div id="vv-step1">
|
||||
|
||||
<!-- Detection banner — populated by JS on load -->
|
||||
<div id="vv-detect-banner">
|
||||
<div class="loading">Detecting environment…</div>
|
||||
</div>
|
||||
|
||||
<?php if ($isConfOnlyFlow): ?>
|
||||
<!-- master.conf already here, just create the local conf -->
|
||||
<div style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:11px 14px;margin-bottom:18px;font-size:12px;color:#666;line-height:1.8;">
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">HOST1:</span> <span style="color:#999;"><?= htmlspecialchars($masterHost1) ?></span></div>
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">This server:</span> <span style="color:#999;"><?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?></span></div>
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">Creating:</span> <span style="color:#999;"><?= htmlspecialchars($myHostId) ?>.conf</span></div>
|
||||
</div>
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
|
||||
|
||||
<?php elseif ($isHost2Flow): ?>
|
||||
<!-- HOST2 pull flow -->
|
||||
<div class="vv-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-hint">Must match Unraid Settings → Identification exactly</div>
|
||||
</div>
|
||||
<div class="vv-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-slot">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#555;margin-bottom:16px;">
|
||||
HOST1 detected: <span style="color:#888;"><?= htmlspecialchars($host1FromState) ?></span><br>
|
||||
Requires SSH keys to be exchanged — run <code>Partnership/ssh_setup.sh</code> first if keys aren't set up.
|
||||
</div>
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Standard first-run: HOST1 -->
|
||||
<div class="vv-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
|
||||
</div>
|
||||
|
||||
<hr class="vv-hr">
|
||||
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
|
||||
<div class="vv-role-row">
|
||||
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
|
||||
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
|
||||
</div>
|
||||
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
|
||||
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-primary">
|
||||
<div class="vv-field">
|
||||
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
|
||||
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-partner">
|
||||
<div class="vv-field">
|
||||
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
|
||||
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="vv-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-partner-slot">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="vv-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
|
||||
<div id="vv-step2">
|
||||
<hr class="vv-hr">
|
||||
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
|
||||
|
||||
<!-- Auto-populate result -->
|
||||
<div id="vv-populate-block">
|
||||
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">
|
||||
⟳ Running auto-populate…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-start guide -->
|
||||
<div class="vv-guide">
|
||||
<strong style="color:#888;">Quick start</strong>
|
||||
<ol>
|
||||
<li>Create your Unraid API key below — needed for live monitor stats</li>
|
||||
<li>Open <strong>Scheduler → Edit host.conf</strong> — only three things need manual entry:<br>
|
||||
<span style="color:#444;">
|
||||
<code>EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
|
||||
<code>DISCORD_WEBHOOK</code> — for notifications (optional)<br>
|
||||
<code>DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
|
||||
Everything else was auto-populated or has working defaults
|
||||
</span></li>
|
||||
<li>If partnering: run <code>Partnership/partnership_onboard.sh</code> once both servers are ready</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- API key -->
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
|
||||
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
|
||||
Create API Key
|
||||
</button>
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
|
||||
</div>
|
||||
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
|
||||
|
||||
<!-- Checklist -->
|
||||
<hr class="vv-hr">
|
||||
<div class="vv-cl-title">Setup checklist</div>
|
||||
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
|
||||
|
||||
<div style="margin-top:18px;text-align:right;">
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">
|
||||
Go to Scheduler →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _vvRedirect = '?tab=scheduler';
|
||||
|
||||
// ── Detection banner ──────────────────────────────────────────────────────────
|
||||
(function() {
|
||||
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const b = document.getElementById('vv-detect-banner');
|
||||
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
|
||||
const modeLabel = d.mode === 'internal'
|
||||
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
|
||||
: '<span style="color:#a84">flash mode (USB boot)</span>';
|
||||
b.innerHTML =
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver || '') + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
|
||||
// Pre-fill hostname if blank
|
||||
const hf = document.getElementById('vv-hostname');
|
||||
if (hf && !hf.value.trim()) hf.value = d.hostname;
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('vv-detect-banner').innerHTML =
|
||||
'<span style="color:#444">Detection unavailable</span>';
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Role toggle (standard flow only) ─────────────────────────────────────────
|
||||
let vvRole = 'primary';
|
||||
function vvSetRole(role) {
|
||||
vvRole = role;
|
||||
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
|
||||
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
|
||||
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
|
||||
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
|
||||
}
|
||||
|
||||
// ── Status helper ─────────────────────────────────────────────────────────────
|
||||
function vvSetStatus(msg, cls) {
|
||||
const s = document.getElementById('vv-status');
|
||||
s.textContent = msg; s.className = cls || '';
|
||||
}
|
||||
|
||||
function vvSetBtn(text, disabled) {
|
||||
const b = document.getElementById('vv-main-btn');
|
||||
if (b) { b.textContent = text; b.disabled = disabled; }
|
||||
}
|
||||
|
||||
// ── Advance to step 2 ─────────────────────────────────────────────────────────
|
||||
function vvShowStep2(redirect, apiKey) {
|
||||
_vvRedirect = redirect || '?tab=scheduler';
|
||||
document.getElementById('vv-step1').style.display = 'none';
|
||||
document.getElementById('vv-step2').style.display = 'block';
|
||||
if (apiKey && apiKey.ok) {
|
||||
const btn = document.getElementById('vv-key-btn');
|
||||
const status = document.getElementById('vv-key-status');
|
||||
if (btn) { btn.textContent = 'Created ✓'; btn.disabled = true; btn.style.opacity = '.6'; }
|
||||
if (status) { status.textContent = '✓ API key created automatically'; status.style.color = '#4a8'; }
|
||||
}
|
||||
vvRunPopulate();
|
||||
vvLoadChecklist();
|
||||
}
|
||||
|
||||
// ── Populate ──────────────────────────────────────────────────────────────────
|
||||
function vvRunPopulate() {
|
||||
const el = document.getElementById('vv-populate-status');
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
|
||||
el.textContent = found.length
|
||||
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
|
||||
: '✓ Auto-populate ran — arr keys will fill once services are running';
|
||||
el.style.color = '#4a8';
|
||||
} else {
|
||||
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
|
||||
el.style.color = '#555';
|
||||
}
|
||||
vvLoadChecklist();
|
||||
}).catch(() => {
|
||||
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
|
||||
el.style.color = '#555';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Checklist ─────────────────────────────────────────────────────────────────
|
||||
const vvActionLabels = {
|
||||
create_key: 'Create API key',
|
||||
ssh_setup: 'SSH guide →',
|
||||
run_populate: 'Run now',
|
||||
onboard: 'Partnership tab →',
|
||||
};
|
||||
const vvActionHref = {
|
||||
ssh_setup: '?tab=partnership',
|
||||
onboard: '?tab=partnership',
|
||||
};
|
||||
|
||||
function vvLoadChecklist() {
|
||||
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
const el = document.getElementById('vv-checklist');
|
||||
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
|
||||
el.innerHTML = d.items.map(item => {
|
||||
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
|
||||
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
|
||||
let act = '';
|
||||
if (item.action) {
|
||||
const lbl = vvActionLabels[item.action] || item.action;
|
||||
const href = vvActionHref[item.action];
|
||||
if (href) {
|
||||
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
|
||||
} else if (item.action === 'create_key') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
|
||||
} else if (item.action === 'run_populate') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
|
||||
}
|
||||
}
|
||||
return `<div class="vv-cl-item">
|
||||
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
|
||||
<div class="vv-cl-body">
|
||||
<div class="vv-cl-label">${item.label}</div>
|
||||
<div class="vv-cl-detail">${item.detail || ''}</div>
|
||||
${act}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function vvRunPopulateBtn(btn) {
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
|
||||
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
|
||||
}
|
||||
|
||||
// ── API key ───────────────────────────────────────────────────────────────────
|
||||
function vvCreateKey(btn) {
|
||||
const status = document.getElementById('vv-key-status');
|
||||
btn.disabled = true; btn.textContent = '⟳ Creating…';
|
||||
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = '✓ Key created — ' + d.key_preview;
|
||||
status.style.color = '#4a8';
|
||||
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
|
||||
vvLoadChecklist();
|
||||
} else {
|
||||
status.textContent = '✗ ' + (d.error || 'Failed');
|
||||
status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
}
|
||||
}).catch(e => {
|
||||
status.textContent = '✗ ' + e; status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
function vvGoScheduler(e) {
|
||||
if (e) e.preventDefault();
|
||||
window.location.href = _vvRedirect || '?tab=scheduler';
|
||||
}
|
||||
|
||||
// ── Action handlers ───────────────────────────────────────────────────────────
|
||||
function vvDoSave() {
|
||||
const hostname = document.getElementById('vv-hostname')?.value.trim();
|
||||
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
|
||||
let host1 = '', host2 = '', mySlot = 'host1';
|
||||
if (vvRole === 'primary') {
|
||||
host1 = hostname;
|
||||
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
|
||||
mySlot = 'host1';
|
||||
} else {
|
||||
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
|
||||
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
|
||||
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
|
||||
host1 = primary;
|
||||
if (mySlot === 'host2') host2 = hostname;
|
||||
}
|
||||
vvSetBtn('Saving…', true);
|
||||
const params = new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
|
||||
else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
|
||||
function vvDoPull() {
|
||||
const hostname = document.getElementById('vv-hostname')?.value.trim();
|
||||
const slot = document.getElementById('vv-slot')?.value || 'host2';
|
||||
if (!hostname) { vvSetStatus('✗ Hostname required', 'err'); return; }
|
||||
vvSetBtn('Pulling…', true);
|
||||
const params = new URLSearchParams({
|
||||
action:'pull', host1_hostname:<?= json_encode($host1FromState) ?>,
|
||||
my_slot:slot, my_hostname:hostname
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
|
||||
else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
|
||||
function vvDoConfOnly() {
|
||||
vvSetBtn('Creating…', true);
|
||||
const params = new URLSearchParams({
|
||||
action:'save',
|
||||
host1: <?= json_encode($masterHost1) ?>,
|
||||
host2: <?= json_encode(trim(preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $_master, $_m2) ? $_m2[1] : '')) ?>,
|
||||
my_slot: <?= json_encode($myHostId) ?>,
|
||||
my_hostname: <?= json_encode($detectedHostname) ?>,
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>), d.api_key); }
|
||||
else { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,529 @@
|
||||
<?php
|
||||
// First-run setup wizard.
|
||||
// Step 1: Auto-detect environment + server identity form.
|
||||
// Step 2: Auto-populate + guide + checklist.
|
||||
// Handles three scenarios:
|
||||
// standard — blank master.conf, this is HOST1
|
||||
// host2-pull — state file pushed by HOST1, pull master.conf via SSH
|
||||
// conf-only — master.conf already here, just create local host.conf
|
||||
|
||||
$detectedHostname = vv_get_hostname();
|
||||
$setupState = vv_setup_state_read();
|
||||
$host1FromState = $setupState['host1_hostname'] ?? '';
|
||||
|
||||
$_master = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
|
||||
$masterHost1 = trim($_h1m[1] ?? '');
|
||||
|
||||
$isHost2Flow = !empty($host1FromState) && empty($masterHost1);
|
||||
$myHostId = vv_detect_host();
|
||||
$confMissing = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
|
||||
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
|
||||
?>
|
||||
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
|
||||
<style>
|
||||
#vv-setup {
|
||||
max-width: 580px; margin: 40px auto 0;
|
||||
background: #141414; border: 1px solid #2a2a2a;
|
||||
border-radius: 6px; padding: 36px 40px 40px;
|
||||
font-family: monospace; color: #ccc;
|
||||
}
|
||||
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
|
||||
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
|
||||
.vv-field { margin-bottom: 18px; }
|
||||
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.vv-field input[type=text],
|
||||
.vv-field select {
|
||||
width: 100%; box-sizing: border-box; background: #0d0d0d;
|
||||
border: 1px solid #333; color: #ddd; padding: 7px 10px;
|
||||
border-radius: 3px; font-family: monospace; font-size: 13px;
|
||||
}
|
||||
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
|
||||
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
|
||||
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
|
||||
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
|
||||
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
|
||||
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
|
||||
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
|
||||
.vv-cond { display: none; }
|
||||
.vv-cond.show { display: block; }
|
||||
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
|
||||
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
|
||||
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
|
||||
cursor: pointer; letter-spacing: .03em; }
|
||||
.vv-btn:hover { border-color: #666; color: #eee; }
|
||||
.vv-btn:disabled { opacity: .4; cursor: default; }
|
||||
.vv-btn-sm { padding: 6px 14px; font-size: 12px; width: auto; }
|
||||
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
|
||||
#vv-status.ok { color: #4a8; }
|
||||
#vv-status.err { color: #a44; }
|
||||
|
||||
/* Detection banner */
|
||||
#vv-detect-banner {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8;
|
||||
color: #666;
|
||||
}
|
||||
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
|
||||
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
|
||||
#vv-detect-banner .vv-det-val { color: #999; }
|
||||
#vv-detect-banner .loading { color: #444; font-style: italic; }
|
||||
|
||||
/* Step 2: guide + checklist */
|
||||
#vv-step2 { display: none; }
|
||||
.vv-guide {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
|
||||
}
|
||||
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
|
||||
.vv-guide li { margin-bottom: 3px; }
|
||||
.vv-guide a { color: #556; }
|
||||
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
|
||||
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-cl-item:last-child { border-bottom: none; }
|
||||
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
|
||||
.vv-cl-body { flex: 1; }
|
||||
.vv-cl-label { color: #bbb; }
|
||||
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
|
||||
.vv-cl-act { margin-top: 5px; }
|
||||
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
|
||||
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
|
||||
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
|
||||
</style>
|
||||
|
||||
<div id="vv-setup">
|
||||
|
||||
<h1>⬡ Varaverk <?php
|
||||
if ($isConfOnlyFlow) echo '— ' . strtoupper($myHostId) . ' Setup';
|
||||
elseif ($isHost2Flow) echo '— Partner Setup';
|
||||
else echo '— First Run';
|
||||
?></h1>
|
||||
<div class="vv-sub"><?php
|
||||
if ($isConfOnlyFlow) echo 'master.conf received. Create your local configuration to continue.';
|
||||
elseif ($isHost2Flow) echo 'HOST1 is configured. Pull their settings to connect.';
|
||||
else echo 'Set up your server before the plugin can start.';
|
||||
?></div>
|
||||
|
||||
<!-- ── Step 1: Detection + identity form ──────────────────────────────────── -->
|
||||
<div id="vv-step1">
|
||||
|
||||
<!-- Detection banner — populated by JS on load -->
|
||||
<div id="vv-detect-banner">
|
||||
<div class="loading">Detecting environment…</div>
|
||||
</div>
|
||||
|
||||
<?php if ($isConfOnlyFlow): ?>
|
||||
<!-- master.conf already here, just create the local conf -->
|
||||
<div style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:11px 14px;margin-bottom:18px;font-size:12px;color:#666;line-height:1.8;">
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">HOST1:</span> <span style="color:#999;"><?= htmlspecialchars($masterHost1) ?></span></div>
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">This server:</span> <span style="color:#999;"><?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?></span></div>
|
||||
<div><span style="color:#555;min-width:60px;display:inline-block;">Creating:</span> <span style="color:#999;"><?= htmlspecialchars($myHostId) ?>.conf</span></div>
|
||||
</div>
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>
|
||||
|
||||
<?php elseif ($isHost2Flow): ?>
|
||||
<!-- HOST2 pull flow -->
|
||||
<div class="vv-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-hint">Must match Unraid Settings → Identification exactly</div>
|
||||
</div>
|
||||
<div class="vv-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-slot">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#555;margin-bottom:14px;">
|
||||
HOST1 detected: <span style="color:#888;"><?= htmlspecialchars($host1FromState) ?></span>
|
||||
</div>
|
||||
|
||||
<!-- SSH key generation -->
|
||||
<div id="vv-ssh-block" style="background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;padding:12px 14px;margin-bottom:16px;">
|
||||
<div style="font-size:11px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;">SSH key</div>
|
||||
<div id="vv-ssh-state" style="font-size:12px;color:#666;margin-bottom:10px;">Required to pull config from HOST1</div>
|
||||
<button class="vv-btn vv-btn-sm" id="vv-ssh-gen-btn" onclick="vvGenerateSshKey(this)">Generate key</button>
|
||||
<div id="vv-ssh-pubkey-block" style="display:none;margin-top:12px;">
|
||||
<div style="font-size:11px;color:#555;margin-bottom:5px;">Add to HOST1's authorized_keys — paste this on HOST1:</div>
|
||||
<div style="position:relative;">
|
||||
<textarea id="vv-ssh-cmd" readonly rows="2"
|
||||
style="width:100%;box-sizing:border-box;background:#060606;border:1px solid #2a2a2a;color:#777;font-family:monospace;font-size:10px;padding:7px 36px 7px 8px;border-radius:2px;resize:none;line-height:1.5;"></textarea>
|
||||
<button onclick="vvCopyCmd(this)" title="Copy"
|
||||
style="position:absolute;right:6px;top:6px;background:none;border:none;color:#555;cursor:pointer;font-size:12px;padding:0;">⎘</button>
|
||||
</div>
|
||||
<div style="font-size:10px;color:#444;margin-top:5px;">Once added, the pull button below will work.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()" disabled>Pull configuration from HOST1 →</button>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Standard first-run: HOST1 -->
|
||||
<div class="vv-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
|
||||
</div>
|
||||
|
||||
<hr class="vv-hr">
|
||||
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
|
||||
<div class="vv-role-row">
|
||||
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
|
||||
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
|
||||
</div>
|
||||
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
|
||||
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-primary">
|
||||
<div class="vv-field">
|
||||
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
|
||||
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-partner">
|
||||
<div class="vv-field">
|
||||
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
|
||||
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="vv-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-partner-slot">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="vv-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
|
||||
<div id="vv-step2">
|
||||
<hr class="vv-hr">
|
||||
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
|
||||
|
||||
<!-- Auto-populate result -->
|
||||
<div id="vv-populate-block">
|
||||
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">
|
||||
⟳ Running auto-populate…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-start guide -->
|
||||
<div class="vv-guide">
|
||||
<strong style="color:#888;">Quick start</strong>
|
||||
<ol>
|
||||
<li>Create your Unraid API key below — needed for live monitor stats</li>
|
||||
<li>Open <strong>Scheduler → Edit host.conf</strong> — only three things need manual entry:<br>
|
||||
<span style="color:#444;">
|
||||
<code>EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
|
||||
<code>DISCORD_WEBHOOK</code> — for notifications (optional)<br>
|
||||
<code>DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
|
||||
Everything else was auto-populated or has working defaults
|
||||
</span></li>
|
||||
<li>If partnering: run <code>Partnership/partnership_onboard.sh</code> once both servers are ready</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- API key -->
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
|
||||
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
|
||||
Create API Key
|
||||
</button>
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
|
||||
</div>
|
||||
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
|
||||
|
||||
<!-- Checklist -->
|
||||
<hr class="vv-hr">
|
||||
<div class="vv-cl-title">Setup checklist</div>
|
||||
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
|
||||
|
||||
<div style="margin-top:18px;text-align:right;">
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">
|
||||
Go to Scheduler →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _vvRedirect = '?tab=scheduler';
|
||||
|
||||
// ── Detection banner ──────────────────────────────────────────────────────────
|
||||
(function() {
|
||||
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const b = document.getElementById('vv-detect-banner');
|
||||
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
|
||||
const modeLabel = d.mode === 'internal'
|
||||
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
|
||||
: '<span style="color:#a84">flash mode (USB boot)</span>';
|
||||
b.innerHTML =
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver || '') + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
|
||||
// Pre-fill hostname if blank
|
||||
const hf = document.getElementById('vv-hostname');
|
||||
if (hf && !hf.value.trim()) hf.value = d.hostname;
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('vv-detect-banner').innerHTML =
|
||||
'<span style="color:#444">Detection unavailable</span>';
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Role toggle (standard flow only) ─────────────────────────────────────────
|
||||
let vvRole = 'primary';
|
||||
function vvSetRole(role) {
|
||||
vvRole = role;
|
||||
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
|
||||
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
|
||||
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
|
||||
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
|
||||
}
|
||||
|
||||
// ── Status helper ─────────────────────────────────────────────────────────────
|
||||
function vvSetStatus(msg, cls) {
|
||||
const s = document.getElementById('vv-status');
|
||||
s.textContent = msg; s.className = cls || '';
|
||||
}
|
||||
|
||||
function vvSetBtn(text, disabled) {
|
||||
const b = document.getElementById('vv-main-btn');
|
||||
if (b) { b.textContent = text; b.disabled = disabled; }
|
||||
}
|
||||
|
||||
// ── Advance to step 2 ─────────────────────────────────────────────────────────
|
||||
function vvShowStep2(redirect, apiKey) {
|
||||
_vvRedirect = redirect || '?tab=scheduler';
|
||||
document.getElementById('vv-step1').style.display = 'none';
|
||||
document.getElementById('vv-step2').style.display = 'block';
|
||||
if (apiKey && apiKey.ok) {
|
||||
const btn = document.getElementById('vv-key-btn');
|
||||
const status = document.getElementById('vv-key-status');
|
||||
if (btn) { btn.textContent = 'Created ✓'; btn.disabled = true; btn.style.opacity = '.6'; }
|
||||
if (status) { status.textContent = '✓ API key created automatically'; status.style.color = '#4a8'; }
|
||||
}
|
||||
vvRunPopulate();
|
||||
vvLoadChecklist();
|
||||
}
|
||||
|
||||
// ── Populate ──────────────────────────────────────────────────────────────────
|
||||
function vvRunPopulate() {
|
||||
const el = document.getElementById('vv-populate-status');
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
|
||||
el.textContent = found.length
|
||||
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
|
||||
: '✓ Auto-populate ran — arr keys will fill once services are running';
|
||||
el.style.color = '#4a8';
|
||||
} else {
|
||||
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
|
||||
el.style.color = '#555';
|
||||
}
|
||||
vvLoadChecklist();
|
||||
}).catch(() => {
|
||||
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
|
||||
el.style.color = '#555';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Checklist ─────────────────────────────────────────────────────────────────
|
||||
const vvActionLabels = {
|
||||
create_key: 'Create API key',
|
||||
ssh_setup: 'SSH guide →',
|
||||
run_populate: 'Run now',
|
||||
onboard: 'Partnership tab →',
|
||||
};
|
||||
const vvActionHref = {
|
||||
ssh_setup: '?tab=partnership',
|
||||
onboard: '?tab=partnership',
|
||||
};
|
||||
|
||||
function vvLoadChecklist() {
|
||||
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
const el = document.getElementById('vv-checklist');
|
||||
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
|
||||
el.innerHTML = d.items.map(item => {
|
||||
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
|
||||
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
|
||||
let act = '';
|
||||
if (item.action) {
|
||||
const lbl = vvActionLabels[item.action] || item.action;
|
||||
const href = vvActionHref[item.action];
|
||||
if (href) {
|
||||
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
|
||||
} else if (item.action === 'create_key') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
|
||||
} else if (item.action === 'run_populate') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
|
||||
}
|
||||
}
|
||||
return `<div class="vv-cl-item">
|
||||
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
|
||||
<div class="vv-cl-body">
|
||||
<div class="vv-cl-label">${item.label}</div>
|
||||
<div class="vv-cl-detail">${item.detail || ''}</div>
|
||||
${act}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function vvRunPopulateBtn(btn) {
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
|
||||
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
|
||||
}
|
||||
|
||||
// ── API key ───────────────────────────────────────────────────────────────────
|
||||
function vvCreateKey(btn) {
|
||||
const status = document.getElementById('vv-key-status');
|
||||
btn.disabled = true; btn.textContent = '⟳ Creating…';
|
||||
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = '✓ Key created — ' + d.key_preview;
|
||||
status.style.color = '#4a8';
|
||||
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
|
||||
vvLoadChecklist();
|
||||
} else {
|
||||
status.textContent = '✗ ' + (d.error || 'Failed');
|
||||
status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
}
|
||||
}).catch(e => {
|
||||
status.textContent = '✗ ' + e; status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
});
|
||||
}
|
||||
|
||||
// ── SSH key generation (HOST2 flow) ──────────────────────────────────────────
|
||||
function vvGenerateSshKey(btn) {
|
||||
btn.disabled = true; btn.textContent = '⟳ Generating…';
|
||||
const state = document.getElementById('vv-ssh-state');
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'ssh_generate'})
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok && d.pubkey) {
|
||||
state.textContent = '✓ Key generated';
|
||||
state.style.color = '#4a8';
|
||||
btn.textContent = 'Regenerate';
|
||||
btn.disabled = false;
|
||||
const host1 = <?= json_encode($host1FromState) ?>;
|
||||
const cmd = `ssh root@${host1} "cat >> ~/.ssh/authorized_keys" <<< '${d.pubkey}'`;
|
||||
document.getElementById('vv-ssh-cmd').value = cmd;
|
||||
document.getElementById('vv-ssh-pubkey-block').style.display = 'block';
|
||||
document.getElementById('vv-main-btn').disabled = false;
|
||||
} else {
|
||||
state.textContent = '✗ ' + (d.error || 'Key generation failed');
|
||||
state.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
}
|
||||
}).catch(() => {
|
||||
state.textContent = '✗ Request failed';
|
||||
state.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
});
|
||||
}
|
||||
|
||||
function vvCopyCmd(btn) {
|
||||
const ta = document.getElementById('vv-ssh-cmd');
|
||||
navigator.clipboard?.writeText(ta.value).then(() => {
|
||||
const orig = btn.textContent; btn.textContent = '✓';
|
||||
setTimeout(() => btn.textContent = orig, 1200);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
function vvGoScheduler(e) {
|
||||
if (e) e.preventDefault();
|
||||
window.location.href = _vvRedirect || '?tab=scheduler';
|
||||
}
|
||||
|
||||
// ── Action handlers ───────────────────────────────────────────────────────────
|
||||
function vvDoSave() {
|
||||
const hostname = document.getElementById('vv-hostname')?.value.trim();
|
||||
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
|
||||
let host1 = '', host2 = '', mySlot = 'host1';
|
||||
if (vvRole === 'primary') {
|
||||
host1 = hostname;
|
||||
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
|
||||
mySlot = 'host1';
|
||||
} else {
|
||||
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
|
||||
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
|
||||
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
|
||||
host1 = primary;
|
||||
if (mySlot === 'host2') host2 = hostname;
|
||||
}
|
||||
vvSetBtn('Saving…', true);
|
||||
const params = new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
|
||||
else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
|
||||
function vvDoPull() {
|
||||
const hostname = document.getElementById('vv-hostname')?.value.trim();
|
||||
const slot = document.getElementById('vv-slot')?.value || 'host2';
|
||||
if (!hostname) { vvSetStatus('✗ Hostname required', 'err'); return; }
|
||||
vvSetBtn('Pulling…', true);
|
||||
const params = new URLSearchParams({
|
||||
action:'pull', host1_hostname:<?= json_encode($host1FromState) ?>,
|
||||
my_slot:slot, my_hostname:hostname
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
|
||||
else { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Pull configuration from HOST1 →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
|
||||
function vvDoConfOnly() {
|
||||
vvSetBtn('Creating…', true);
|
||||
const params = new URLSearchParams({
|
||||
action:'save',
|
||||
host1: <?= json_encode($masterHost1) ?>,
|
||||
host2: <?= json_encode(trim(preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $_master, $_m2) ? $_m2[1] : '')) ?>,
|
||||
my_slot: <?= json_encode($myHostId) ?>,
|
||||
my_hostname: <?= json_encode($detectedHostname) ?>,
|
||||
});
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:params
|
||||
}).then(r=>r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>), d.api_key); }
|
||||
else { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Create <?= htmlspecialchars($myHostId) ?>.conf and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
// First-run setup wizard — uniform flow for all hosts.
|
||||
// Step 1: auto-detect environment + server identity form.
|
||||
// Step 2: auto-populate + guide + checklist.
|
||||
// master.conf pull (for partner servers) lives in the checklist, not here.
|
||||
|
||||
$detectedHostname = vv_get_hostname();
|
||||
?>
|
||||
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">
|
||||
<style>
|
||||
#vv-setup {
|
||||
max-width: 580px; margin: 40px auto 0;
|
||||
background: #141414; border: 1px solid #2a2a2a;
|
||||
border-radius: 6px; padding: 36px 40px 40px;
|
||||
font-family: monospace; color: #ccc;
|
||||
}
|
||||
#vv-setup h1 { margin: 0 0 4px; font-size: 17px; color: #e0e0e0; font-weight: normal; letter-spacing: .04em; }
|
||||
.vv-sub { font-size: 12px; color: #555; margin-bottom: 28px; }
|
||||
.vv-field { margin-bottom: 18px; }
|
||||
.vv-field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.vv-field input[type=text],
|
||||
.vv-field select {
|
||||
width: 100%; box-sizing: border-box; background: #0d0d0d;
|
||||
border: 1px solid #333; color: #ddd; padding: 7px 10px;
|
||||
border-radius: 3px; font-family: monospace; font-size: 13px;
|
||||
}
|
||||
.vv-field input:focus, .vv-field select:focus { outline: none; border-color: #555; }
|
||||
.vv-hint { font-size: 11px; color: #555; margin-top: 4px; }
|
||||
.vv-role-row { display: flex; gap: 10px; margin-bottom: 22px; }
|
||||
.vv-role-btn { flex: 1; padding: 9px 0; background: #1a1a1a; border: 1px solid #333;
|
||||
border-radius: 3px; color: #777; font-family: monospace; font-size: 12px;
|
||||
cursor: pointer; text-align: center; transition: border-color .15s, color .15s; }
|
||||
.vv-role-btn.active { border-color: #555; color: #ccc; background: #1e1e1e; }
|
||||
.vv-cond { display: none; }
|
||||
.vv-cond.show { display: block; }
|
||||
hr.vv-hr { border: none; border-top: 1px solid #1e1e1e; margin: 22px 0; }
|
||||
.vv-btn { width: 100%; padding: 10px; background: #1e1e1e; border: 1px solid #444;
|
||||
color: #ccc; font-family: monospace; font-size: 13px; border-radius: 3px;
|
||||
cursor: pointer; letter-spacing: .03em; }
|
||||
.vv-btn:hover { border-color: #666; color: #eee; }
|
||||
.vv-btn:disabled { opacity: .4; cursor: default; }
|
||||
#vv-status { margin-top: 10px; font-size: 12px; color: #666; text-align: center; min-height: 16px; }
|
||||
#vv-status.ok { color: #4a8; }
|
||||
#vv-status.err { color: #a44; }
|
||||
|
||||
/* Detection banner */
|
||||
#vv-detect-banner {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 11px 14px; margin-bottom: 22px; font-size: 12px; line-height: 1.8; color: #666;
|
||||
}
|
||||
#vv-detect-banner .vv-det-row { display: flex; gap: 8px; }
|
||||
#vv-detect-banner .vv-det-lbl { color: #555; min-width: 100px; }
|
||||
#vv-detect-banner .vv-det-val { color: #999; }
|
||||
#vv-detect-banner .loading { color: #444; font-style: italic; }
|
||||
|
||||
/* Step 2 */
|
||||
#vv-step2 { display: none; }
|
||||
.vv-guide {
|
||||
background: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
padding: 13px 16px; margin-bottom: 20px; font-size: 12px; color: #666; line-height: 1.9;
|
||||
}
|
||||
.vv-guide ol { margin: 8px 0 0 16px; padding: 0; }
|
||||
.vv-guide li { margin-bottom: 3px; }
|
||||
.vv-cl-title { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
|
||||
.vv-cl-item { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0;
|
||||
border-bottom: 1px solid #1a1a1a; font-size: 12px; }
|
||||
.vv-cl-item:last-child { border-bottom: none; }
|
||||
.vv-cl-icon { font-size: 13px; min-width: 16px; margin-top: 1px; }
|
||||
.vv-cl-body { flex: 1; }
|
||||
.vv-cl-label { color: #bbb; }
|
||||
.vv-cl-detail{ color: #555; font-size: 11px; margin-top: 2px; }
|
||||
.vv-cl-act { margin-top: 5px; }
|
||||
.vv-cl-act button { padding: 4px 10px; background: #1a1a1a; border: 1px solid #333; color: #888;
|
||||
font-family: monospace; font-size: 11px; border-radius: 2px; cursor: pointer; }
|
||||
.vv-cl-act button:hover { border-color: #555; color: #bbb; }
|
||||
.vv-cl-err { font-size: 11px; color: #a44; margin-top: 4px; }
|
||||
</style>
|
||||
|
||||
<div id="vv-setup">
|
||||
|
||||
<h1>⬡ Varaverk — First Run</h1>
|
||||
<div class="vv-sub">Set up this server before the plugin can start.</div>
|
||||
|
||||
<!-- ── Step 1: Detection + identity ──────────────────────────────────────── -->
|
||||
<div id="vv-step1">
|
||||
|
||||
<div id="vv-detect-banner"><div class="loading">Detecting environment…</div></div>
|
||||
|
||||
<div class="vv-field">
|
||||
<label>This server's hostname</label>
|
||||
<input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">
|
||||
<div class="vv-hint">Must match Unraid Settings → Identification exactly (case-sensitive)</div>
|
||||
</div>
|
||||
|
||||
<hr class="vv-hr">
|
||||
<label style="display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Server role</label>
|
||||
<div class="vv-role-row">
|
||||
<div class="vv-role-btn active" id="vv-role-primary" onclick="vvSetRole('primary')">
|
||||
Primary<br><span style="color:#555;font-size:10px;">HOST1 · first server</span>
|
||||
</div>
|
||||
<div class="vv-role-btn" id="vv-role-partner" onclick="vvSetRole('partner')">
|
||||
Partner<br><span style="color:#555;font-size:10px;">HOST2+ · joining primary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-primary">
|
||||
<div class="vv-field">
|
||||
<label>Partner's hostname <span style="color:#444;font-size:10px;">(optional — can fill in later)</span></label>
|
||||
<input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-cond" id="vv-cond-partner">
|
||||
<div class="vv-field">
|
||||
<label>Primary server's hostname <span style="color:#a44;font-size:10px;">required</span></label>
|
||||
<input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="vv-field">
|
||||
<label>Your slot</label>
|
||||
<select id="vv-partner-slot">
|
||||
<option value="host2">HOST2</option>
|
||||
<option value="host3">HOST3</option>
|
||||
<option value="host4">HOST4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#555;margin-bottom:4px;">
|
||||
SSH key and master.conf pull are handled automatically after save.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
|
||||
<div id="vv-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 2: Populate + guide + checklist ───────────────────────────────── -->
|
||||
<div id="vv-step2">
|
||||
<hr class="vv-hr">
|
||||
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">Step 2 of 2</div>
|
||||
|
||||
<div id="vv-populate-status" style="font-size:12px;color:#555;margin-bottom:14px;">⟳ Running auto-populate…</div>
|
||||
|
||||
<div class="vv-guide">
|
||||
<strong style="color:#888;">Quick start</strong>
|
||||
<ol>
|
||||
<li>Create your Unraid API key below — needed for live monitor stats</li>
|
||||
<li>Open <strong>Scheduler → Edit host.conf</strong> — only three things need manual entry:<br>
|
||||
<span style="color:#444;">
|
||||
<code>EMBY_API_KEY</code> — Emby Dashboard → API Keys → + New Key<br>
|
||||
<code>DISCORD_WEBHOOK</code> — for notifications (optional)<br>
|
||||
<code>DAILY_SYNC_SHARES</code> — media paths to rsync nightly<br>
|
||||
Everything else was auto-populated or has working defaults
|
||||
</span></li>
|
||||
<li>If partnering: the checklist below will guide you through pulling HOST1's config and running onboard</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;">
|
||||
<button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
|
||||
Create API Key
|
||||
</button>
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
|
||||
</div>
|
||||
<div id="vv-key-status" style="font-size:12px;min-height:14px;margin-bottom:18px;"></div>
|
||||
|
||||
<hr class="vv-hr">
|
||||
<div class="vv-cl-title">Setup checklist</div>
|
||||
<div id="vv-checklist"><div style="font-size:12px;color:#444;">Loading…</div></div>
|
||||
|
||||
<div style="margin-top:18px;text-align:right;">
|
||||
<a href="#" onclick="vvGoScheduler(event)" style="font-size:12px;color:#444;text-decoration:none;">Go to Scheduler →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _vvRedirect = '?tab=scheduler';
|
||||
|
||||
// ── Detection banner ──────────────────────────────────────────────────────────
|
||||
(function() {
|
||||
fetch('/plugins/varaverk/api/setup.php?action=detect&_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
const b = document.getElementById('vv-detect-banner');
|
||||
if (!d.ok) { b.innerHTML = '<span style="color:#555">Detection unavailable</span>'; return; }
|
||||
const modeLabel = d.mode === 'internal'
|
||||
? '<span style="color:#4a8">internal (NVMe/SSD)</span>'
|
||||
: '<span style="color:#a84">flash mode (USB boot)</span>';
|
||||
b.innerHTML =
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">OS</span><span class="vv-det-val">Unraid ' + (d.unraid_ver||'') + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Boot device</span><span class="vv-det-val">' + d.boot_device + ' (' + d.transport + ')</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Storage mode</span><span class="vv-det-val">' + modeLabel + '</span></div>' +
|
||||
'<div class="vv-det-row"><span class="vv-det-lbl">Scripts dir</span><span class="vv-det-val" style="color:#666">' + d.scripts_dir + '</span></div>';
|
||||
const hf = document.getElementById('vv-hostname');
|
||||
if (hf && !hf.value.trim()) hf.value = d.hostname;
|
||||
}).catch(() => {
|
||||
document.getElementById('vv-detect-banner').innerHTML = '<span style="color:#444">Detection unavailable</span>';
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Role toggle ───────────────────────────────────────────────────────────────
|
||||
let vvRole = 'primary';
|
||||
function vvSetRole(role) {
|
||||
vvRole = role;
|
||||
document.getElementById('vv-role-primary')?.classList.toggle('active', role === 'primary');
|
||||
document.getElementById('vv-role-partner')?.classList.toggle('active', role === 'partner');
|
||||
document.getElementById('vv-cond-primary')?.classList.toggle('show', role === 'primary');
|
||||
document.getElementById('vv-cond-partner')?.classList.toggle('show', role === 'partner');
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function vvSetStatus(msg, cls) {
|
||||
const s = document.getElementById('vv-status');
|
||||
s.textContent = msg; s.className = cls || '';
|
||||
}
|
||||
function vvSetBtn(text, disabled) {
|
||||
const b = document.getElementById('vv-main-btn');
|
||||
if (b) { b.textContent = text; b.disabled = disabled; }
|
||||
}
|
||||
function vvGoScheduler(e) {
|
||||
if (e) e.preventDefault();
|
||||
window.location.href = _vvRedirect || '?tab=scheduler';
|
||||
}
|
||||
|
||||
// ── Step 2 ────────────────────────────────────────────────────────────────────
|
||||
function vvShowStep2(redirect, apiKey) {
|
||||
_vvRedirect = redirect || '?tab=scheduler';
|
||||
document.getElementById('vv-step1').style.display = 'none';
|
||||
document.getElementById('vv-step2').style.display = 'block';
|
||||
if (apiKey && apiKey.ok) {
|
||||
const btn = document.getElementById('vv-key-btn');
|
||||
const status = document.getElementById('vv-key-status');
|
||||
if (btn) { btn.textContent = 'Created ✓'; btn.disabled = true; btn.style.opacity = '.6'; }
|
||||
if (status) { status.textContent = '✓ API key created automatically'; status.style.color = '#4a8'; }
|
||||
}
|
||||
vvRunPopulate();
|
||||
vvLoadChecklist();
|
||||
}
|
||||
|
||||
// ── Populate ──────────────────────────────────────────────────────────────────
|
||||
function vvRunPopulate() {
|
||||
const el = document.getElementById('vv-populate-status');
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
const found = (d.lines || []).filter(l => /✅|found|detected/i.test(l));
|
||||
el.textContent = found.length
|
||||
? '✓ Auto-populate: ' + found.length + ' field' + (found.length > 1 ? 's' : '') + ' detected'
|
||||
: '✓ Auto-populate ran — arr keys will fill once services are running';
|
||||
el.style.color = '#4a8';
|
||||
} else {
|
||||
el.textContent = 'Auto-populate skipped — run Tools/conf_populate.sh once your arr containers are up';
|
||||
el.style.color = '#555';
|
||||
}
|
||||
vvLoadChecklist();
|
||||
}).catch(() => {
|
||||
el.textContent = 'Auto-populate unavailable — run manually from Scheduler';
|
||||
el.style.color = '#555';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Checklist ─────────────────────────────────────────────────────────────────
|
||||
const vvActionLabels = {
|
||||
create_key: 'Create API key',
|
||||
ssh_setup: 'SSH guide →',
|
||||
run_populate: 'Run now',
|
||||
pull_master: 'Pull from HOST1',
|
||||
onboard: 'Partnership tab →',
|
||||
};
|
||||
const vvActionHref = {
|
||||
ssh_setup: '?tab=partnership',
|
||||
onboard: '?tab=partnership',
|
||||
};
|
||||
|
||||
function vvLoadChecklist() {
|
||||
fetch('/plugins/varaverk/api/checklist.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
const el = document.getElementById('vv-checklist');
|
||||
if (!d.ok || !d.items) { el.innerHTML = '<span style="color:#555">Unable to load checklist</span>'; return; }
|
||||
el.innerHTML = d.items.map(item => {
|
||||
const icon = item.ok === null ? '○' : (item.ok ? '✓' : '✗');
|
||||
const iclr = item.ok === null ? '#444' : (item.ok ? '#4a8' : '#a66');
|
||||
let act = '';
|
||||
if (item.action) {
|
||||
const lbl = vvActionLabels[item.action] || item.action;
|
||||
const href = vvActionHref[item.action];
|
||||
if (href) {
|
||||
act = `<div class="vv-cl-act"><a href="${href}" style="font-size:11px;color:#556;">${lbl}</a></div>`;
|
||||
} else if (item.action === 'create_key') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvCreateKey(this)">${lbl}</button></div>`;
|
||||
} else if (item.action === 'run_populate') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvRunPopulateBtn(this)">${lbl}</button></div>`;
|
||||
} else if (item.action === 'pull_master') {
|
||||
act = `<div class="vv-cl-act"><button onclick="vvPullMaster(this)">${lbl}</button><div id="vv-pull-err" class="vv-cl-err"></div></div>`;
|
||||
}
|
||||
}
|
||||
return `<div class="vv-cl-item">
|
||||
<div class="vv-cl-icon" style="color:${iclr}">${icon}</div>
|
||||
<div class="vv-cl-body">
|
||||
<div class="vv-cl-label">${item.label}</div>
|
||||
<div class="vv-cl-detail">${item.detail || ''}</div>
|
||||
${act}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function vvRunPopulateBtn(btn) {
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'populate'})
|
||||
}).then(() => { btn.textContent = 'Done'; vvLoadChecklist(); })
|
||||
.catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
|
||||
}
|
||||
|
||||
function vvPullMaster(btn) {
|
||||
btn.disabled = true; btn.textContent = '⟳ Pulling…';
|
||||
const errEl = document.getElementById('vv-pull-err');
|
||||
if (errEl) errEl.textContent = '';
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action: 'pull'})
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
btn.textContent = '✓ Done';
|
||||
setTimeout(vvLoadChecklist, 600);
|
||||
} else {
|
||||
if (errEl) errEl.textContent = d.error || 'Pull failed';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
}
|
||||
}).catch(() => { btn.disabled = false; btn.textContent = 'Retry'; });
|
||||
}
|
||||
|
||||
// ── API key ───────────────────────────────────────────────────────────────────
|
||||
function vvCreateKey(btn) {
|
||||
const status = document.getElementById('vv-key-status');
|
||||
btn.disabled = true; btn.textContent = '⟳ Creating…';
|
||||
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
|
||||
.then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = '✓ Key created — ' + d.key_preview;
|
||||
status.style.color = '#4a8';
|
||||
btn.textContent = 'Created ✓'; btn.style.opacity = '.6';
|
||||
vvLoadChecklist();
|
||||
} else {
|
||||
status.textContent = '✗ ' + (d.error || 'Failed');
|
||||
status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
}
|
||||
}).catch(e => {
|
||||
status.textContent = '✗ ' + e; status.style.color = '#a44';
|
||||
btn.disabled = false; btn.textContent = 'Retry';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────────
|
||||
function vvDoSave() {
|
||||
const hostname = document.getElementById('vv-hostname')?.value.trim();
|
||||
if (!hostname) { vvSetStatus('✗ Hostname is required', 'err'); return; }
|
||||
let host1 = '', host2 = '', mySlot = 'host1';
|
||||
if (vvRole === 'primary') {
|
||||
host1 = hostname;
|
||||
host2 = document.getElementById('vv-partner-hostname')?.value.trim() || '';
|
||||
mySlot = 'host1';
|
||||
} else {
|
||||
const primary = document.getElementById('vv-primary-hostname')?.value.trim();
|
||||
if (!primary) { vvSetStatus('✗ Primary hostname required', 'err'); return; }
|
||||
mySlot = document.getElementById('vv-partner-slot')?.value || 'host2';
|
||||
host1 = primary;
|
||||
if (mySlot === 'host2') host2 = hostname;
|
||||
}
|
||||
vvSetBtn('Saving…', true);
|
||||
fetch('/plugins/varaverk/api/setup.php', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({action:'save', host1, host2, my_slot:mySlot, my_hostname:hostname})
|
||||
}).then(r => r.json()).then(d => {
|
||||
if (d.ok) { vvShowStep2(d.redirect || '?tab=scheduler', d.api_key); }
|
||||
else { vvSetBtn('Save and continue →', false); vvSetStatus('✗ ' + (d.error||'Error'), 'err'); }
|
||||
}).catch(() => { vvSetBtn('Save and continue →', false); vvSetStatus('✗ Request failed', 'err'); });
|
||||
}
|
||||
</script>
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= SMART Extended Self-Test =======================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Runs a SMART extended (long) self-test on all drives (or a specific drive),
|
||||
# waits for completion, and reports results. Extended tests do a full read-scan
|
||||
# of every sector — catches bad sectors that the short test skips. Monthly cadence
|
||||
# via the monthly maintenance orchestrator.
|
||||
#
|
||||
# NVMe drives are included — smartctl supports NVMe self-test via the same
|
||||
# -t long interface (NVMe 1.3+ specification).
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL MODEL
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Starts long self-tests on all drives sequentially (NOT in parallel) to avoid
|
||||
# saturating I/O. One drive at a time: start, wait for completion, move to next.
|
||||
# Sequential order keeps test duration predictable and avoids thermal stacking.
|
||||
#
|
||||
# Drives already running a self-test are skipped. If a previous test did not
|
||||
# finish (interrupted mid-way), it is reported and the drive is re-tested.
|
||||
#
|
||||
# Each test is polled every 60 seconds. Typical durations:
|
||||
# HDD 2–4 TB : 60–120 min
|
||||
# HDD 8–12 TB: 90–180 min
|
||||
# SSD any : 5–15 min
|
||||
# NVMe any : 5–10 min
|
||||
#
|
||||
# ==============================================================================================
|
||||
# OPERATIONAL SAFEGUARDS
|
||||
# ==============================================================================================
|
||||
#
|
||||
# Single Instance Lock
|
||||
# acquire_lock prevents concurrent test runs — one long-test pass per server.
|
||||
#
|
||||
# Sequential Execution
|
||||
# Drives tested one at a time. Parallel long-tests thrash I/O and inflate temps.
|
||||
#
|
||||
# SIGTERM Trap
|
||||
# Poll loop exits cleanly on signal. Tests continue running in drive firmware.
|
||||
#
|
||||
# Tool Validation
|
||||
# platform_require_cmd confirms smartctl is present before use.
|
||||
#
|
||||
# Silent When Clean
|
||||
# Only failures and warnings produce notifications.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# CONFIGURATION
|
||||
# ==============================================================================================
|
||||
#
|
||||
# host*.conf
|
||||
#
|
||||
# HOST*_SMART_IGNORE_DRIVES
|
||||
# Drives skipped in SMART testing. Aliased by detect_hosts() →
|
||||
# SMART_IGNORE_DRIVES. Typically includes the boot USB flash drive.
|
||||
#
|
||||
# master.conf
|
||||
#
|
||||
# SMART_TEMP_WARN / SMART_TEMP_CRIT
|
||||
# Fallback thresholds used for post-test temperature reporting if
|
||||
# dynamix.cfg is not found.
|
||||
#
|
||||
# ==============================================================================================
|
||||
# RUNTIME MODES
|
||||
# ==============================================================================================
|
||||
#
|
||||
# smart_long_test.sh
|
||||
# Run extended self-test on all drives not in SMART_IGNORE_DRIVES.
|
||||
#
|
||||
# smart_long_test.sh /dev/sda
|
||||
# Run extended self-test on a specific drive. Bypasses the ignore list.
|
||||
#
|
||||
# smart_long_test.sh --status
|
||||
# Show last self-test result for all drives and exit.
|
||||
#
|
||||
# smart_long_test.sh --dry-run
|
||||
# Show which drives would be tested. No tests started.
|
||||
#
|
||||
# smart_long_test.sh --log
|
||||
# Verbose progress output during polling.
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
TARGET_DRIVE="${PARSED_ARGS[0]:-}"
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Setup ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
error "Must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
platform_require_cmd \
|
||||
"$(command -v smartctl 2>/dev/null || echo /usr/bin/smartctl)" \
|
||||
"--version" "smartmontools" \
|
||||
"smartctl" || {
|
||||
error "smartctl not found — install smartmontools"
|
||||
notify "SMART long test failed on $(hostname) — smartmontools not installed" \
|
||||
"SMART Long Test" "warning"
|
||||
exit 1
|
||||
}
|
||||
|
||||
platform_require_cmd \
|
||||
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
|
||||
"" "" \
|
||||
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
|
||||
|
||||
acquire_lock
|
||||
|
||||
detect_hosts
|
||||
get_unraid_temp_thresholds
|
||||
|
||||
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
log "Ignore: ${SMART_IGNORE_DRIVES[*]:-none}"
|
||||
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no tests will be started"
|
||||
|
||||
trap 'warn "SMART long test script interrupted — tests continue in drive firmware"; exit 0' \
|
||||
SIGTERM SIGINT
|
||||
|
||||
# ==============================================================================================
|
||||
# ── Build drive list ──────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
_build_drive_list() {
|
||||
local drives=()
|
||||
if [[ -n "$TARGET_DRIVE" ]]; then
|
||||
if [[ ! -e "$TARGET_DRIVE" ]]; then
|
||||
error "Drive not found: $TARGET_DRIVE"
|
||||
exit 1
|
||||
fi
|
||||
drives=("$TARGET_DRIVE")
|
||||
else
|
||||
for drive in /dev/sd? /dev/nvme?; do
|
||||
[[ ! -e "$drive" ]] && continue
|
||||
local drive_name
|
||||
drive_name=$(basename "$drive")
|
||||
local ignored=false
|
||||
for ignore in "${SMART_IGNORE_DRIVES[@]:-}"; do
|
||||
[[ "$drive_name" == "$ignore" ]] && ignored=true && break
|
||||
done
|
||||
[[ "$ignored" == true ]] && { log "Skipping $drive_name (SMART_IGNORE_DRIVES)"; continue; }
|
||||
if ! smartctl -i "$drive" 2>/dev/null | grep -q "SMART support is: Enabled"; then
|
||||
log "Skipping $drive_name — SMART not enabled"
|
||||
continue
|
||||
fi
|
||||
drives+=("$drive")
|
||||
done
|
||||
fi
|
||||
echo "${drives[@]}"
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Status ━━━
|
||||
# ==============================================================================================
|
||||
if [[ "$SHOW_STATUS" == true ]]; then
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY SMART LONG TEST STATUS ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo ""
|
||||
for drive in /dev/sd? /dev/nvme?; do
|
||||
[[ ! -e "$drive" ]] && continue
|
||||
drive_name=$(basename "$drive")
|
||||
local_result=$(smartctl -l selftest "$drive" 2>/dev/null | \
|
||||
grep -m1 "Extended" | awk '{print $NF}')
|
||||
ignored=false
|
||||
for ignore in "${SMART_IGNORE_DRIVES[@]:-}"; do
|
||||
[[ "$drive_name" == "$ignore" ]] && ignored=true && break
|
||||
done
|
||||
if [[ "$ignored" == true ]]; then
|
||||
echo " $ICON_WARN $drive_name — ignored"
|
||||
else
|
||||
echo " $ICON_SMART $drive_name — last extended: ${local_result:-no result}"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " Ignored: ${SMART_IGNORE_DRIVES[*]:-none}"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Run Extended Tests — Sequential ━━━
|
||||
# ==============================================================================================
|
||||
read -r -a DRIVES_TO_TEST <<< "$(_build_drive_list)"
|
||||
|
||||
if [[ ${#DRIVES_TO_TEST[@]} -eq 0 ]]; then
|
||||
warn "No drives to test — all may be on ignore list or SMART not enabled"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_SMART SMART Extended Self-Test — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
echo "$ICON_HOST ${#DRIVES_TO_TEST[@]} drive(s) to test — running sequentially"
|
||||
echo ""
|
||||
|
||||
WINDOW_START=$(date +%s)
|
||||
DRIVES_OK=()
|
||||
DRIVES_FAIL=()
|
||||
DRIVES_SKIP=()
|
||||
|
||||
for drive in "${DRIVES_TO_TEST[@]}"; do
|
||||
drive_name=$(basename "$drive")
|
||||
echo "━━━ $ICON_SMART $drive_name ━━━"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — would run extended self-test on $drive_name"
|
||||
echo ""
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if test already in progress
|
||||
if smartctl -l selftest "$drive" 2>/dev/null | grep -q "Self-test routine in progress"; then
|
||||
warn "$drive_name — self-test already in progress — skipping"
|
||||
DRIVES_SKIP+=("$drive_name")
|
||||
echo ""
|
||||
continue
|
||||
fi
|
||||
|
||||
# Start extended test
|
||||
if ! smartctl -t long "$drive" >/dev/null 2>&1; then
|
||||
error "$drive_name — failed to start extended self-test"
|
||||
DRIVES_FAIL+=("$drive_name")
|
||||
echo ""
|
||||
continue
|
||||
fi
|
||||
echo " Extended test started on $drive_name"
|
||||
|
||||
# Poll until complete
|
||||
DRIVE_START=$(date +%s)
|
||||
while true; do
|
||||
sleep 60
|
||||
|
||||
STATUS_LINE=$(smartctl -l selftest "$drive" 2>/dev/null | grep -m1 "Extended")
|
||||
|
||||
if echo "$STATUS_LINE" | grep -q "Self-test routine in progress"; then
|
||||
PCT=$(echo "$STATUS_LINE" | grep -oE "[0-9]+% of test remaining" || true)
|
||||
log "$drive_name — test in progress ${PCT:+($PCT)}"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Test finished — determine result
|
||||
DRIVE_ELAPSED=$(format_duration $(( $(date +%s) - DRIVE_START )))
|
||||
if echo "$STATUS_LINE" | grep -iq "Completed without error\|Successful"; then
|
||||
echo " $ICON_DONE $drive_name — extended test passed ✅ ($DRIVE_ELAPSED)"
|
||||
DRIVES_OK+=("$drive_name")
|
||||
elif echo "$STATUS_LINE" | grep -iq "Failed\|failed"; then
|
||||
FAIL_DETAIL=$(echo "$STATUS_LINE" | awk '{print $NF}')
|
||||
error "$drive_name — extended test FAILED — $FAIL_DETAIL ($DRIVE_ELAPSED)"
|
||||
DRIVES_FAIL+=("$drive_name")
|
||||
elif [[ -z "$STATUS_LINE" ]]; then
|
||||
warn "$drive_name — no test result found — may not support extended test"
|
||||
DRIVES_SKIP+=("$drive_name")
|
||||
else
|
||||
warn "$drive_name — unexpected result: $STATUS_LINE ($DRIVE_ELAPSED)"
|
||||
DRIVES_SKIP+=("$drive_name")
|
||||
fi
|
||||
echo ""
|
||||
break
|
||||
done
|
||||
done
|
||||
|
||||
WINDOW_END=$(date +%s)
|
||||
|
||||
# ==============================================================================================
|
||||
# ━━━ Summary ━━━
|
||||
# ==============================================================================================
|
||||
echo "━━━━━ $ICON_SUMMARY SMART LONG TEST SUMMARY ━━━━━"
|
||||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||||
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
|
||||
echo "$ICON_SMART Drives: ${#DRIVES_TO_TEST[@]} tested"
|
||||
[[ ${#DRIVES_OK[@]} -gt 0 ]] && echo "$ICON_DONE Passed: ${DRIVES_OK[*]}"
|
||||
[[ ${#DRIVES_FAIL[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${DRIVES_FAIL[*]}"
|
||||
[[ ${#DRIVES_SKIP[@]} -gt 0 ]] && echo "$ICON_WARN Skipped: ${DRIVES_SKIP[*]}"
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN — no tests started"
|
||||
elif [[ ${#DRIVES_FAIL[@]} -gt 0 ]]; then
|
||||
echo "$ICON_ERROR Status: FAILURES — ${DRIVES_FAIL[*]}"
|
||||
notify "SMART long test failures on $(hostname) ($MY_ID) — drives: ${DRIVES_FAIL[*]}" \
|
||||
"SMART Long Test" "warning"
|
||||
elif [[ ${#DRIVES_OK[@]} -gt 0 ]]; then
|
||||
echo "$ICON_DONE Status: all ${#DRIVES_OK[@]} drive(s) passed ✅"
|
||||
else
|
||||
warn "Status: no results — all drives skipped"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
[[ ${#DRIVES_FAIL[@]} -gt 0 ]] && exit 1
|
||||
exit 0
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = trim($_POST['action'] ?? 'save');
|
||||
|
||||
// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
|
||||
if ($action === 'pull') {
|
||||
$host1Hostname = trim($_POST['host1_hostname'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host2');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (!$host1Hostname) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
|
||||
// Derive SSH key path from this server's hostname
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
|
||||
$sshKey = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
if (!file_exists($sshKey)) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Resolve HOST1 Tailscale IP
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
|
||||
if (!$ip) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get HOST1's SCRIPTS_DIR from their varaverk.cfg
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
|
||||
preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
|
||||
$remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
|
||||
|
||||
// SCP master.conf from HOST1
|
||||
$localMaster = CONF_DIR . '/master.conf';
|
||||
$src = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
if ($rc !== 0) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
'SCP failed: ' . implode('; ', $out) .
|
||||
' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host conf from template if it doesn't exist
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$hostname = $myHostname ?: vv_get_hostname();
|
||||
$sshKeyPath = $sshKey;
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKeyPath . '"', $conf);
|
||||
vv_write_conf_raw($confFile, $conf);
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
|
||||
'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
|
||||
$host1 = trim($_POST['host1'] ?? '');
|
||||
$host2 = trim($_POST['host2'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host1');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (empty($host1)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Write HOST1 / HOST2 into master.conf
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
if ($master === '') {
|
||||
echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
|
||||
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);
|
||||
|
||||
$slotNum = (int) preg_replace('/\D/', '', $mySlot);
|
||||
if ($slotNum > 2 && !empty($myHostname)) {
|
||||
$hostKey = 'HOST' . $slotNum;
|
||||
if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
|
||||
$master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
|
||||
'$1' . "\n {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
|
||||
} else {
|
||||
$master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
|
||||
'${1}"' . addslashes($myHostname) . '"', $master);
|
||||
}
|
||||
}
|
||||
|
||||
if (!vv_write_conf_raw('master.conf', $master)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host*.conf from template
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
|
||||
$sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKeyPath . '"', $conf);
|
||||
if (!vv_write_conf_raw($confFile, $conf)) {
|
||||
echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write setup state file — lets partner servers know HOST1 is configured
|
||||
vv_setup_state_write(['host1_hostname' => $host1]);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'host_id' => $hostId,
|
||||
'redirect' => '?tab=scheduler&vv_setup=master.conf',
|
||||
]);
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
|
||||
? trim($_GET['action'] ?? '')
|
||||
: trim($_POST['action'] ?? 'save');
|
||||
|
||||
// ── GET: detect environment ────────────────────────────────────────────────────────────────────
|
||||
if ($action === 'detect') {
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart
|
||||
? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '')
|
||||
: '';
|
||||
$transport = $bootDisk
|
||||
? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: ''))
|
||||
: 'unknown';
|
||||
|
||||
$isUsb = ($transport === 'usb');
|
||||
|
||||
preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'hostname' => vv_get_hostname(),
|
||||
'unraid_ver' => $vm[1] ?? 'unknown',
|
||||
'transport' => $transport,
|
||||
'boot_device' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
|
||||
'mode' => $isUsb ? 'flash' : 'internal',
|
||||
'scripts_dir' => SCRIPTS_DIR,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
|
||||
if ($action === 'populate') {
|
||||
$script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
|
||||
$lines = array_values(array_filter(array_map('trim', $out)));
|
||||
echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
|
||||
if ($action === 'pull') {
|
||||
$host1Hostname = trim($_POST['host1_hostname'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host2');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (!$host1Hostname) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
|
||||
// Derive SSH key path from this server's hostname
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
|
||||
$sshKey = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
if (!file_exists($sshKey)) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Resolve HOST1 Tailscale IP
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
|
||||
if (!$ip) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get HOST1's SCRIPTS_DIR from their varaverk.cfg
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
|
||||
preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
|
||||
$remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
|
||||
|
||||
// SCP master.conf from HOST1
|
||||
$localMaster = CONF_DIR . '/master.conf';
|
||||
$src = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
if ($rc !== 0) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
'SCP failed: ' . implode('; ', $out) .
|
||||
' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host conf from template if it doesn't exist
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$bootPart2 = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk2 = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKey . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal2, $conf);
|
||||
vv_write_conf_raw($confFile, $conf);
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
|
||||
'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
|
||||
$host1 = trim($_POST['host1'] ?? '');
|
||||
$host2 = trim($_POST['host2'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host1');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (empty($host1)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Write HOST1 / HOST2 into master.conf
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
if ($master === '') {
|
||||
echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
|
||||
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);
|
||||
|
||||
$slotNum = (int) preg_replace('/\D/', '', $mySlot);
|
||||
if ($slotNum > 2 && !empty($myHostname)) {
|
||||
$hostKey = 'HOST' . $slotNum;
|
||||
if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
|
||||
$master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
|
||||
'$1' . "\n {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
|
||||
} else {
|
||||
$master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
|
||||
'${1}"' . addslashes($myHostname) . '"', $master);
|
||||
}
|
||||
}
|
||||
|
||||
if (!vv_write_conf_raw('master.conf', $master)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host*.conf from template
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
|
||||
$sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
// Auto-detect storage mode from boot device transport
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal = ($transport !== 'usb') ? 'true' : 'false';
|
||||
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKeyPath . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal, $conf);
|
||||
if (!vv_write_conf_raw($confFile, $conf)) {
|
||||
echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write setup state file — lets partner servers know HOST1 is configured
|
||||
vv_setup_state_write(['host1_hostname' => $host1]);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'host_id' => $hostId,
|
||||
'redirect' => '?tab=scheduler&vv_setup=master.conf',
|
||||
]);
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
|
||||
? trim($_GET['action'] ?? '')
|
||||
: trim($_POST['action'] ?? 'save');
|
||||
|
||||
// ── GET: detect environment ────────────────────────────────────────────────────────────────────
|
||||
if ($action === 'detect') {
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart
|
||||
? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '')
|
||||
: '';
|
||||
$transport = $bootDisk
|
||||
? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: ''))
|
||||
: 'unknown';
|
||||
|
||||
$isUsb = ($transport === 'usb');
|
||||
|
||||
preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'hostname' => vv_get_hostname(),
|
||||
'unraid_ver' => $vm[1] ?? 'unknown',
|
||||
'transport' => $transport,
|
||||
'boot_device' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
|
||||
'mode' => $isUsb ? 'flash' : 'internal',
|
||||
'scripts_dir' => SCRIPTS_DIR,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
|
||||
if ($action === 'populate') {
|
||||
$script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
|
||||
$lines = array_values(array_filter(array_map('trim', $out)));
|
||||
echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
|
||||
if ($action === 'pull') {
|
||||
$host1Hostname = trim($_POST['host1_hostname'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host2');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (!$host1Hostname) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
|
||||
// Derive SSH key path from this server's hostname
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
|
||||
$sshKey = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
if (!file_exists($sshKey)) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Resolve HOST1 Tailscale IP
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
|
||||
if (!$ip) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get HOST1's SCRIPTS_DIR from their varaverk.cfg
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
|
||||
preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
|
||||
$remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
|
||||
|
||||
// SCP master.conf from HOST1
|
||||
$localMaster = CONF_DIR . '/master.conf';
|
||||
$src = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
if ($rc !== 0) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
'SCP failed: ' . implode('; ', $out) .
|
||||
' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host conf from template if it doesn't exist
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$bootPart2 = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk2 = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKey . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal2, $conf);
|
||||
vv_write_conf_raw($confFile, $conf);
|
||||
}
|
||||
}
|
||||
|
||||
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
|
||||
|
||||
echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
|
||||
'api_key' => $apiKeyResult,
|
||||
'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
|
||||
$host1 = trim($_POST['host1'] ?? '');
|
||||
$host2 = trim($_POST['host2'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host1');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (empty($host1)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Write HOST1 / HOST2 into master.conf
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
if ($master === '') {
|
||||
echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
|
||||
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);
|
||||
|
||||
$slotNum = (int) preg_replace('/\D/', '', $mySlot);
|
||||
if ($slotNum > 2 && !empty($myHostname)) {
|
||||
$hostKey = 'HOST' . $slotNum;
|
||||
if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
|
||||
$master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
|
||||
'$1' . "\n {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
|
||||
} else {
|
||||
$master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
|
||||
'${1}"' . addslashes($myHostname) . '"', $master);
|
||||
}
|
||||
}
|
||||
|
||||
if (!vv_write_conf_raw('master.conf', $master)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host*.conf from template
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
|
||||
$sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
// Auto-detect storage mode from boot device transport
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal = ($transport !== 'usb') ? 'true' : 'false';
|
||||
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKeyPath . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal, $conf);
|
||||
if (!vv_write_conf_raw($confFile, $conf)) {
|
||||
echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write setup state file — lets partner servers know HOST1 is configured
|
||||
vv_setup_state_write(['host1_hostname' => $host1]);
|
||||
|
||||
// Auto-create Unraid API key and write into the fresh conf
|
||||
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'host_id' => $hostId,
|
||||
'api_key' => $apiKeyResult,
|
||||
'redirect' => '?tab=scheduler&vv_setup=master.conf',
|
||||
]);
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
|
||||
? trim($_GET['action'] ?? '')
|
||||
: trim($_POST['action'] ?? 'save');
|
||||
|
||||
// ── GET: detect environment ────────────────────────────────────────────────────────────────────
|
||||
if ($action === 'detect') {
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart
|
||||
? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '')
|
||||
: '';
|
||||
$transport = $bootDisk
|
||||
? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: ''))
|
||||
: 'unknown';
|
||||
|
||||
$isUsb = ($transport === 'usb');
|
||||
|
||||
preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'hostname' => vv_get_hostname(),
|
||||
'unraid_ver' => $vm[1] ?? 'unknown',
|
||||
'transport' => $transport,
|
||||
'boot_device' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
|
||||
'mode' => $isUsb ? 'flash' : 'internal',
|
||||
'scripts_dir' => SCRIPTS_DIR,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── GET/POST: generate local SSH keypair ──────────────────────────────────────────────────────
|
||||
if ($action === 'ssh_generate') {
|
||||
$script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
|
||||
// Derive pubkey path from hostname
|
||||
$hostname = vv_get_hostname();
|
||||
$shortName = strtolower(preg_replace('/^unraid-/i', '', $hostname));
|
||||
$pubPath = '/root/.ssh/' . $shortName . '_rsync_automation.pub';
|
||||
$pubKey = trim(@file_get_contents($pubPath) ?: '');
|
||||
echo json_encode([
|
||||
'ok' => $rc === 0 && !empty($pubKey),
|
||||
'pubkey' => $pubKey,
|
||||
'error' => ($rc !== 0) ? implode(' ', array_slice(array_filter(array_map('trim', $out)), -3)) : null,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
|
||||
if ($action === 'populate') {
|
||||
$script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
|
||||
$lines = array_values(array_filter(array_map('trim', $out)));
|
||||
echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
|
||||
if ($action === 'pull') {
|
||||
$host1Hostname = trim($_POST['host1_hostname'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host2');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (!$host1Hostname) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
|
||||
// Derive SSH key path from this server's hostname
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
|
||||
$sshKey = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
if (!file_exists($sshKey)) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Resolve HOST1 Tailscale IP
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
|
||||
if (!$ip) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get HOST1's SCRIPTS_DIR from their varaverk.cfg
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
|
||||
preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
|
||||
$remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
|
||||
|
||||
// SCP master.conf from HOST1
|
||||
$localMaster = CONF_DIR . '/master.conf';
|
||||
$src = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
if ($rc !== 0) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
'SCP failed: ' . implode('; ', $out) .
|
||||
' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host conf from template if it doesn't exist
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$bootPart2 = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk2 = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKey . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal2, $conf);
|
||||
vv_write_conf_raw($confFile, $conf);
|
||||
}
|
||||
}
|
||||
|
||||
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
|
||||
|
||||
echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
|
||||
'api_key' => $apiKeyResult,
|
||||
'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
|
||||
$host1 = trim($_POST['host1'] ?? '');
|
||||
$host2 = trim($_POST['host2'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host1');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (empty($host1)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Write HOST1 / HOST2 into master.conf
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
if ($master === '') {
|
||||
echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
|
||||
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);
|
||||
|
||||
$slotNum = (int) preg_replace('/\D/', '', $mySlot);
|
||||
if ($slotNum > 2 && !empty($myHostname)) {
|
||||
$hostKey = 'HOST' . $slotNum;
|
||||
if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
|
||||
$master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
|
||||
'$1' . "\n {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
|
||||
} else {
|
||||
$master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
|
||||
'${1}"' . addslashes($myHostname) . '"', $master);
|
||||
}
|
||||
}
|
||||
|
||||
if (!vv_write_conf_raw('master.conf', $master)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host*.conf from template
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
|
||||
$sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
// Auto-detect storage mode from boot device transport
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal = ($transport !== 'usb') ? 'true' : 'false';
|
||||
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKeyPath . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal, $conf);
|
||||
if (!vv_write_conf_raw($confFile, $conf)) {
|
||||
echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write setup state file — lets partner servers know HOST1 is configured
|
||||
vv_setup_state_write(['host1_hostname' => $host1]);
|
||||
|
||||
// Auto-create Unraid API key and write into the fresh conf
|
||||
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'host_id' => $hostId,
|
||||
'api_key' => $apiKeyResult,
|
||||
'redirect' => '?tab=scheduler&vv_setup=master.conf',
|
||||
]);
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
|
||||
? trim($_GET['action'] ?? '')
|
||||
: trim($_POST['action'] ?? 'save');
|
||||
|
||||
// ── GET: detect environment ────────────────────────────────────────────────────────────────────
|
||||
if ($action === 'detect') {
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart
|
||||
? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '')
|
||||
: '';
|
||||
$transport = $bootDisk
|
||||
? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: ''))
|
||||
: 'unknown';
|
||||
|
||||
$isUsb = ($transport === 'usb');
|
||||
|
||||
preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'hostname' => vv_get_hostname(),
|
||||
'unraid_ver' => $vm[1] ?? 'unknown',
|
||||
'transport' => $transport,
|
||||
'boot_device' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
|
||||
'mode' => $isUsb ? 'flash' : 'internal',
|
||||
'scripts_dir' => SCRIPTS_DIR,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── GET/POST: generate local SSH keypair ──────────────────────────────────────────────────────
|
||||
if ($action === 'ssh_generate') {
|
||||
$script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
|
||||
// Derive pubkey path from hostname
|
||||
$hostname = vv_get_hostname();
|
||||
$shortName = strtolower(preg_replace('/^unraid-/i', '', $hostname));
|
||||
$pubPath = '/root/.ssh/' . $shortName . '_rsync_automation.pub';
|
||||
$pubKey = trim(@file_get_contents($pubPath) ?: '');
|
||||
echo json_encode([
|
||||
'ok' => $rc === 0 && !empty($pubKey),
|
||||
'pubkey' => $pubKey,
|
||||
'error' => ($rc !== 0) ? implode(' ', array_slice(array_filter(array_map('trim', $out)), -3)) : null,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
|
||||
if ($action === 'populate') {
|
||||
$script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
|
||||
$lines = array_values(array_filter(array_map('trim', $out)));
|
||||
echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
|
||||
|
||||
// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
|
||||
if ($action === 'pull') {
|
||||
$host1Hostname = trim($_POST['host1_hostname'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host2');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (!$host1Hostname) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
|
||||
// Derive SSH key path from this server's hostname
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
|
||||
$sshKey = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
if (!file_exists($sshKey)) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Resolve HOST1 Tailscale IP
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
|
||||
if (!$ip) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get HOST1's SCRIPTS_DIR from their varaverk.cfg
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
|
||||
preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
|
||||
$remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
|
||||
|
||||
// SCP master.conf from HOST1
|
||||
$localMaster = CONF_DIR . '/master.conf';
|
||||
$src = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
if ($rc !== 0) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
'SCP failed: ' . implode('; ', $out) .
|
||||
' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host conf from template if it doesn't exist
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$bootPart2 = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk2 = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKey . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal2, $conf);
|
||||
vv_write_conf_raw($confFile, $conf);
|
||||
}
|
||||
}
|
||||
|
||||
if (file_exists($sshScript)) {
|
||||
exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
|
||||
}
|
||||
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
|
||||
|
||||
echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
|
||||
'api_key' => $apiKeyResult,
|
||||
'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
|
||||
$host1 = trim($_POST['host1'] ?? '');
|
||||
$host2 = trim($_POST['host2'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host1');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (empty($host1)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Write HOST1 / HOST2 into master.conf
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
if ($master === '') {
|
||||
echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
|
||||
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);
|
||||
|
||||
$slotNum = (int) preg_replace('/\D/', '', $mySlot);
|
||||
if ($slotNum > 2 && !empty($myHostname)) {
|
||||
$hostKey = 'HOST' . $slotNum;
|
||||
if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
|
||||
$master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
|
||||
'$1' . "\n {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
|
||||
} else {
|
||||
$master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
|
||||
'${1}"' . addslashes($myHostname) . '"', $master);
|
||||
}
|
||||
}
|
||||
|
||||
if (!vv_write_conf_raw('master.conf', $master)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host*.conf from template
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
|
||||
$sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
// Auto-detect storage mode from boot device transport
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal = ($transport !== 'usb') ? 'true' : 'false';
|
||||
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKeyPath . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal, $conf);
|
||||
if (!vv_write_conf_raw($confFile, $conf)) {
|
||||
echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write setup state file — lets partner servers know HOST1 is configured
|
||||
vv_setup_state_write(['host1_hostname' => $host1]);
|
||||
|
||||
// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
|
||||
if (file_exists($sshScript)) {
|
||||
exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
|
||||
}
|
||||
|
||||
// Auto-create Unraid API key and write into the fresh conf
|
||||
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'host_id' => $hostId,
|
||||
'api_key' => $apiKeyResult,
|
||||
'redirect' => '?tab=scheduler&vv_setup=master.conf',
|
||||
]);
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
|
||||
? trim($_GET['action'] ?? '')
|
||||
: trim($_POST['action'] ?? 'save');
|
||||
|
||||
// ── GET: detect environment ────────────────────────────────────────────────────────────────────
|
||||
if ($action === 'detect') {
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart
|
||||
? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '')
|
||||
: '';
|
||||
$transport = $bootDisk
|
||||
? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: ''))
|
||||
: 'unknown';
|
||||
|
||||
$isUsb = ($transport === 'usb');
|
||||
|
||||
preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'hostname' => vv_get_hostname(),
|
||||
'unraid_ver' => $vm[1] ?? 'unknown',
|
||||
'transport' => $transport,
|
||||
'boot_device' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
|
||||
'mode' => $isUsb ? 'flash' : 'internal',
|
||||
'scripts_dir' => SCRIPTS_DIR,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── GET/POST: generate local SSH keypair ──────────────────────────────────────────────────────
|
||||
if ($action === 'ssh_generate') {
|
||||
$script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
|
||||
// Derive pubkey path from hostname
|
||||
$hostname = vv_get_hostname();
|
||||
$shortName = strtolower(preg_replace('/^unraid-/i', '', $hostname));
|
||||
$pubPath = '/root/.ssh/' . $shortName . '_rsync_automation.pub';
|
||||
$pubKey = trim(@file_get_contents($pubPath) ?: '');
|
||||
echo json_encode([
|
||||
'ok' => $rc === 0 && !empty($pubKey),
|
||||
'pubkey' => $pubKey,
|
||||
'error' => ($rc !== 0) ? implode(' ', array_slice(array_filter(array_map('trim', $out)), -3)) : null,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
|
||||
if ($action === 'populate') {
|
||||
$script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
|
||||
$lines = array_values(array_filter(array_map('trim', $out)));
|
||||
echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
|
||||
|
||||
// ── Pull master.conf from HOST1 via SSH (wizard or checklist) ────────────────────────────────
|
||||
if ($action === 'pull') {
|
||||
$mySlot = trim($_POST['my_slot'] ?? '') ?: strtolower(vv_detect_host());
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '') ?: vv_get_hostname();
|
||||
$host1Hostname = trim($_POST['host1_hostname'] ?? '');
|
||||
if (!$host1Hostname) {
|
||||
$masterRaw = vv_read_conf_raw('master.conf');
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $masterRaw, $_mh);
|
||||
$host1Hostname = trim($_mh[1] ?? '');
|
||||
}
|
||||
if (!$host1Hostname) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname not set — fill in master.conf first']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
|
||||
// Derive SSH key path from this server's hostname
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
|
||||
$sshKey = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
if (!file_exists($sshKey)) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Resolve HOST1 Tailscale IP
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
|
||||
if (!$ip) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
"Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get HOST1's SCRIPTS_DIR from their varaverk.cfg
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
|
||||
preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
|
||||
$remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
|
||||
|
||||
// SCP master.conf from HOST1
|
||||
$localMaster = CONF_DIR . '/master.conf';
|
||||
$src = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
if ($rc !== 0) {
|
||||
echo json_encode(['ok' => false, 'error' =>
|
||||
'SCP failed: ' . implode('; ', $out) .
|
||||
' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host conf from template if it doesn't exist
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$bootPart2 = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk2 = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKey . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal2, $conf);
|
||||
vv_write_conf_raw($confFile, $conf);
|
||||
}
|
||||
}
|
||||
|
||||
if (file_exists($sshScript)) {
|
||||
exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
|
||||
}
|
||||
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
|
||||
|
||||
$state = vv_setup_state_read();
|
||||
$state['master_conf_pulled'] = 'true';
|
||||
vv_setup_state_write($state);
|
||||
|
||||
echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
|
||||
'api_key' => $apiKeyResult,
|
||||
'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
|
||||
$host1 = trim($_POST['host1'] ?? '');
|
||||
$host2 = trim($_POST['host2'] ?? '');
|
||||
$mySlot = trim($_POST['my_slot'] ?? 'host1');
|
||||
$myHostname = trim($_POST['my_hostname'] ?? '');
|
||||
|
||||
if (empty($host1)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
|
||||
exit;
|
||||
}
|
||||
if (!preg_match('/^host\d+$/', $mySlot)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Write HOST1 / HOST2 into master.conf
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
if ($master === '') {
|
||||
echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
|
||||
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);
|
||||
|
||||
$slotNum = (int) preg_replace('/\D/', '', $mySlot);
|
||||
if ($slotNum > 2 && !empty($myHostname)) {
|
||||
$hostKey = 'HOST' . $slotNum;
|
||||
if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
|
||||
$master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
|
||||
'$1' . "\n {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
|
||||
} else {
|
||||
$master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
|
||||
'${1}"' . addslashes($myHostname) . '"', $master);
|
||||
}
|
||||
}
|
||||
|
||||
if (!vv_write_conf_raw('master.conf', $master)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create host*.conf from template
|
||||
$hostId = strtoupper($mySlot);
|
||||
$hostIdLow = strtolower($mySlot);
|
||||
$confFile = $hostIdLow . '.conf';
|
||||
|
||||
if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
$template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
|
||||
if ($template) {
|
||||
$sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
|
||||
$sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
|
||||
|
||||
// Auto-detect storage mode from boot device transport
|
||||
$bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
|
||||
$bootDisk = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : '';
|
||||
$transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : '';
|
||||
$storageInternal = ($transport !== 'usb') ? 'true' : 'false';
|
||||
|
||||
$conf = str_replace('HOSTN', $hostId, $template);
|
||||
$conf = str_replace('hostn', $hostIdLow, $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
|
||||
'${1}"' . $sshKeyPath . '"', $conf);
|
||||
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
|
||||
'${1}' . $storageInternal, $conf);
|
||||
if (!vv_write_conf_raw($confFile, $conf)) {
|
||||
echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write setup state file — lets partner servers know HOST1 is configured
|
||||
vv_setup_state_write(['host1_hostname' => $host1]);
|
||||
|
||||
// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
|
||||
if (file_exists($sshScript)) {
|
||||
exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
|
||||
}
|
||||
|
||||
// Auto-create Unraid API key and write into the fresh conf
|
||||
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'host_id' => $hostId,
|
||||
'api_key' => $apiKeyResult,
|
||||
'redirect' => '?tab=scheduler&vv_setup=master.conf',
|
||||
]);
|
||||
@@ -0,0 +1,143 @@
|
||||
<style>
|
||||
/* ── Cert page ───────────────────────────────────────────────── */
|
||||
.vv-cert-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px; }
|
||||
.vv-cert-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:12px 14px; }
|
||||
.vv-cert-domain { font-size:12px;font-weight:700;color:#aaa;word-break:break-all;margin-bottom:8px; }
|
||||
.vv-cert-days { font-size:28px;font-weight:700;line-height:1;margin-bottom:2px; }
|
||||
.vv-cert-sub { font-size:10px;color:#444;margin-bottom:6px; }
|
||||
.vv-cert-pill { display:inline-block;font-size:9px;padding:2px 8px;border-radius:3px;font-weight:700; }
|
||||
.vv-cert-pill.ok { background:#0d1f0d;color:#4caf50;border:1px solid #1a3a1a; }
|
||||
.vv-cert-pill.warn { background:#1f1500;color:#ffb74d;border:1px solid #3a2800; }
|
||||
.vv-cert-pill.crit { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
|
||||
.vv-cert-pill.fail { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
|
||||
.vv-cert-pill.unkn { background:#1e1e1e;color:#444;border:1px solid #2a2a2a; }
|
||||
.vv-cert-bar { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
|
||||
.vv-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
|
||||
.vv-cert-run-btn { background:#1a1a1a;border:1px solid #333;color:#888;font-size:10px;
|
||||
padding:4px 12px;border-radius:3px;cursor:pointer;transition:border-color .15s; }
|
||||
.vv-cert-run-btn:hover { border-color:#555;color:#aaa; }
|
||||
.vv-cert-run-btn:disabled { opacity:.4;cursor:default; }
|
||||
</style>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
|
||||
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Cert Monitor</span>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<span id="vv-cert-ts" style="font-size:11px;color:#3a3a3a;"></span>
|
||||
<button class="vv-cert-run-btn" id="vv-cert-run" onclick="vvCertRunNow()">Run now</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vv-cert-grid" class="vv-cert-grid">
|
||||
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">Loading…</div>
|
||||
</div>
|
||||
|
||||
<div id="vv-cert-log" style="display:none;margin-top:12px;background:#0d0d0d;border:1px solid #1e1e1e;
|
||||
border-radius:4px;padding:10px 12px;font-size:10px;color:#555;font-family:monospace;
|
||||
max-height:160px;overflow-y:auto;white-space:pre-wrap;"></div>
|
||||
|
||||
<div id="vv-cert-cfg" style="margin-top:12px;font-size:10px;color:#3a3a3a;padding:0 2px;"></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
|
||||
function _pillCls(s) {
|
||||
return ({OK:'ok', WARN:'warn', CRIT:'crit', FAIL:'fail'})[s] || 'unkn';
|
||||
}
|
||||
function _pillTxt(s) {
|
||||
return ({OK:'healthy', WARN:'warning', CRIT:'critical', FAIL:'failed', UNKN:'not checked'})[s] || s;
|
||||
}
|
||||
function _dayColor(days, warnDays, critDays) {
|
||||
if (days == null) return '#3a3a3a';
|
||||
if (days <= critDays) return '#ef5350';
|
||||
if (days <= warnDays) return '#ffb74d';
|
||||
return '#4caf50';
|
||||
}
|
||||
function _barColor(days, warnDays, critDays) {
|
||||
if (days == null) return '#1e1e1e';
|
||||
if (days <= critDays) return '#ef5350';
|
||||
if (days <= warnDays) return '#ffb74d';
|
||||
return '#4caf50';
|
||||
}
|
||||
function _rel(ts) {
|
||||
if (!ts) return '—';
|
||||
const d = Math.floor(Date.now() / 1000) - ts;
|
||||
if (d < 60) return 'just now';
|
||||
if (d < 3600) return Math.floor(d/60) + 'm ago';
|
||||
if (d < 86400) return Math.floor(d/3600) + 'h ago';
|
||||
if (d < 604800)return Math.floor(d/86400) + 'd ago';
|
||||
return Math.floor(d/604800) + 'w ago';
|
||||
}
|
||||
|
||||
function _render(data) {
|
||||
const grid = document.getElementById('vv-cert-grid');
|
||||
const ts = document.getElementById('vv-cert-ts');
|
||||
const cfg = document.getElementById('vv-cert-cfg');
|
||||
|
||||
const warnDays = data.warn_days || 30;
|
||||
const critDays = data.crit_days || 7;
|
||||
const checked = data.checked_at;
|
||||
|
||||
ts.textContent = checked ? 'Last checked: ' + _rel(checked) : '';
|
||||
cfg.textContent = `Warn: ${warnDays}d · Crit: ${critDays}d`;
|
||||
|
||||
const domains = data.domains || [];
|
||||
if (!domains.length) {
|
||||
grid.innerHTML = `<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">
|
||||
No domains configured — add HOST*_CERT_MONITOR_DOMAINS to your host.conf</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = domains.map(d => {
|
||||
const s = d.status || 'UNKN';
|
||||
const days = d.days;
|
||||
const barPct = (days != null && days >= 0) ? Math.min(Math.round(days / 90 * 100), 100) : 0;
|
||||
const dayCol = _dayColor(days, warnDays, critDays);
|
||||
const barCol = _barColor(days, warnDays, critDays);
|
||||
const pillCls = _pillCls(s);
|
||||
const pillTxt = _pillTxt(s);
|
||||
const daysStr = days != null ? String(days) : '—';
|
||||
const expStr = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : 'Unknown expiry');
|
||||
|
||||
return `<div class="vv-cert-card">
|
||||
<div class="vv-cert-domain">${d.domain}</div>
|
||||
<div class="vv-cert-days" style="color:${dayCol};">${daysStr}</div>
|
||||
<div class="vv-cert-sub">${days != null ? 'days remaining' : ''}</div>
|
||||
<span class="vv-cert-pill ${pillCls}">${pillTxt}</span>
|
||||
<div style="font-size:9px;color:#3a3a3a;margin-top:5px;">${expStr}</div>
|
||||
${days != null ? `<div class="vv-cert-bar"><div class="vv-cert-fill" style="width:${barPct}%;background:${barCol};"></div></div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function vvCertLoad() {
|
||||
fetch('/plugins/varaverk/api/cert.php')
|
||||
.then(r => r.json()).then(d => { if (d.ok) _render(d); })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
vvCertLoad();
|
||||
|
||||
window.vvCertRunNow = function() {
|
||||
const btn = document.getElementById('vv-cert-run');
|
||||
const log = document.getElementById('vv-cert-log');
|
||||
btn.disabled = true; btn.textContent = 'Checking…';
|
||||
log.style.display = 'none'; log.textContent = '';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('action', 'run');
|
||||
fetch('/plugins/varaverk/api/cert.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
btn.disabled = false; btn.textContent = 'Run now';
|
||||
if (d.data) _render(d.data);
|
||||
if (d.output && d.output.length) {
|
||||
log.style.display = 'block';
|
||||
// Strip ANSI escape codes
|
||||
log.textContent = d.output.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
})
|
||||
.catch(() => { btn.disabled = false; btn.textContent = 'Run now'; });
|
||||
};
|
||||
|
||||
})();
|
||||
</script>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Remote Arr Cache Writer ========================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# SSHes to each remote partner host, calls vv_arrs_local_node() on their PHP
|
||||
# stack, and caches the result locally in /tmp/vv_cache/arrs_remote_hostN.json.
|
||||
#
|
||||
# The arrs page reads these files for instant initial load without hitting the
|
||||
# remote arr APIs on every page view. This script runs every 2 hours so remote
|
||||
# library counts stay reasonably current without hammering the network.
|
||||
#
|
||||
# Accepts --host=HOST2 to refresh a single host (used by the UI refresh button).
|
||||
#
|
||||
# ==============================================================================================
|
||||
# USAGE
|
||||
# ==============================================================================================
|
||||
#
|
||||
# remote_arr_cache_writer.sh — refresh all remote hosts
|
||||
# remote_arr_cache_writer.sh --host=HOST2 — refresh one host only
|
||||
# remote_arr_cache_writer.sh --dry-run — show what would happen
|
||||
# remote_arr_cache_writer.sh --log — verbose output
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../load_config.sh"
|
||||
|
||||
parse_args "$@"
|
||||
acquire_lock
|
||||
detect_hosts
|
||||
|
||||
# Parse --host= from raw args
|
||||
TARGET_HOST=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in --host=*) TARGET_HOST="${arg#--host=}" ;; esac
|
||||
done
|
||||
|
||||
mkdir -p /tmp/vv_cache
|
||||
|
||||
log "$ICON_GEAR Config: target=${TARGET_HOST:-all hosts} ssh-key=${SSH_KEY}"
|
||||
|
||||
FETCH_OK=0
|
||||
FETCH_FAIL=0
|
||||
FETCH_SKIP=0
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Remote Arr Cache Writer ━━━"
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
[[ -n "$TARGET_HOST" ]] && echo " Target: $TARGET_HOST"
|
||||
echo ""
|
||||
|
||||
for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
|
||||
[[ "$host_var" == "$MY_ID" ]] && continue
|
||||
|
||||
hostname="${!host_var:-}"
|
||||
[[ -z "$hostname" ]] && continue
|
||||
|
||||
# If targeting a specific host, skip others
|
||||
if [[ -n "$TARGET_HOST" ]]; then
|
||||
[[ "${host_var,,}" != "${TARGET_HOST,,}" && "$host_var" != "$TARGET_HOST" ]] && continue
|
||||
fi
|
||||
|
||||
host_id="${host_var,,}" # host1, host2, …
|
||||
cache_file="/tmp/vv_cache/arrs_remote_${host_id}.json"
|
||||
|
||||
echo " $host_var ($hostname)…"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would SSH to $hostname and cache arr data"
|
||||
(( FETCH_SKIP++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Resolve Tailscale IP
|
||||
REMOTE_IP=$(resolve_tailscale_ip "$hostname")
|
||||
if [[ -z "$REMOTE_IP" ]]; then
|
||||
warn " $host_var: cannot resolve Tailscale IP for $hostname — skipping"
|
||||
(( FETCH_FAIL++ ))
|
||||
continue
|
||||
fi
|
||||
log " $host_var: resolved $hostname → $REMOTE_IP"
|
||||
|
||||
# Single SSH connection — fetch arrs + monitor stats together
|
||||
RESULT=$(ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout=10 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
"root@${REMOTE_IP}" \
|
||||
"php -r \"
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/arrs.php';
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/unraid_api.php';
|
||||
echo json_encode([
|
||||
'arrs' => vv_arrs_local_node(),
|
||||
'monitor' => vv_local_host_stats(),
|
||||
]);
|
||||
\"" 2>/dev/null)
|
||||
|
||||
if [[ -z "$RESULT" ]]; then
|
||||
warn " $host_var: empty SSH response — skipping"
|
||||
(( FETCH_FAIL++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validate combined JSON
|
||||
if ! echo "$RESULT" | php -r "
|
||||
\$d = json_decode(file_get_contents('php://stdin'), true);
|
||||
exit(!is_array(\$d) || !isset(\$d['arrs'], \$d['monitor']) ? 1 : 0);
|
||||
" 2>/dev/null; then
|
||||
warn " $host_var: invalid JSON — skipping"
|
||||
log " Response: ${RESULT:0:200}"
|
||||
(( FETCH_FAIL++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Save arr cache
|
||||
echo "$RESULT" | php -r "
|
||||
\$d = json_decode(file_get_contents('php://stdin'), true);
|
||||
file_put_contents('$cache_file', json_encode(\$d['arrs']));
|
||||
" 2>/dev/null
|
||||
|
||||
# Save monitor cache
|
||||
MONITOR_CACHE="/tmp/vv_cache/monitor_remote_${host_id}.json"
|
||||
echo "$RESULT" | php -r "
|
||||
\$d = json_decode(file_get_contents('php://stdin'), true);
|
||||
file_put_contents('$MONITOR_CACHE', json_encode(\$d['monitor']));
|
||||
" 2>/dev/null
|
||||
|
||||
CACHED_TYPES=$(echo "$RESULT" | php -r "
|
||||
\$d = json_decode(file_get_contents('php://stdin'), true);
|
||||
echo implode(', ', array_column(\$d['arrs']['arrs'] ?? [], 'type'));
|
||||
" 2>/dev/null)
|
||||
echo " $host_var: arrs [${CACHED_TYPES:-none}] + monitor stats cached ✅"
|
||||
(( FETCH_OK++ ))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY Cache Writer Summary ━━━━━"
|
||||
echo " $FETCH_OK updated · $FETCH_FAIL failed · $FETCH_SKIP skipped"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Remote Arr Cache Writer ========================================
|
||||
# ==============================================================================================
|
||||
#
|
||||
# PURPOSE
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# SSHes to each remote partner host, calls vv_arrs_local_node() on their PHP
|
||||
# stack, and caches the result locally in /tmp/vv_cache/arrs_remote_hostN.json.
|
||||
#
|
||||
# The arrs page reads these files for instant initial load without hitting the
|
||||
# remote arr APIs on every page view. This script runs every 2 hours so remote
|
||||
# library counts stay reasonably current without hammering the network.
|
||||
#
|
||||
# Accepts --host=HOST2 to refresh a single host (used by the UI refresh button).
|
||||
#
|
||||
# ==============================================================================================
|
||||
# USAGE
|
||||
# ==============================================================================================
|
||||
#
|
||||
# remote_arr_cache_writer.sh — refresh all remote hosts
|
||||
# remote_arr_cache_writer.sh --host=HOST2 — refresh one host only
|
||||
# remote_arr_cache_writer.sh --dry-run — show what would happen
|
||||
# remote_arr_cache_writer.sh --log — verbose output
|
||||
#
|
||||
# ==============================================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../../../load_config.sh"
|
||||
|
||||
parse_args "$@"
|
||||
acquire_lock
|
||||
detect_hosts
|
||||
|
||||
# Parse --host= from raw args
|
||||
TARGET_HOST=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in --host=*) TARGET_HOST="${arg#--host=}" ;; esac
|
||||
done
|
||||
|
||||
mkdir -p /tmp/vv_cache
|
||||
|
||||
log "$ICON_GEAR Config: target=${TARGET_HOST:-all hosts} ssh-key=${SSH_KEY}"
|
||||
|
||||
FETCH_OK=0
|
||||
FETCH_FAIL=0
|
||||
FETCH_SKIP=0
|
||||
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Remote Arr Cache Writer ━━━"
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
|
||||
[[ -n "$TARGET_HOST" ]] && echo " Target: $TARGET_HOST"
|
||||
echo ""
|
||||
|
||||
for host_var in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
|
||||
[[ "$host_var" == "$MY_ID" ]] && continue
|
||||
|
||||
hostname="${!host_var:-}"
|
||||
[[ -z "$hostname" ]] && continue
|
||||
|
||||
# If targeting a specific host, skip others
|
||||
if [[ -n "$TARGET_HOST" ]]; then
|
||||
[[ "${host_var,,}" != "${TARGET_HOST,,}" && "$host_var" != "$TARGET_HOST" ]] && continue
|
||||
fi
|
||||
|
||||
host_id="${host_var,,}" # host1, host2, …
|
||||
cache_file="/tmp/vv_cache/arrs_remote_${host_id}.json"
|
||||
|
||||
echo " $host_var ($hostname)…"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn " DRY RUN — would SSH to $hostname and cache arr data"
|
||||
(( FETCH_SKIP++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Resolve Tailscale IP
|
||||
REMOTE_IP=$(resolve_tailscale_ip "$hostname")
|
||||
if [[ -z "$REMOTE_IP" ]]; then
|
||||
warn " $host_var: cannot resolve Tailscale IP for $hostname — skipping"
|
||||
(( FETCH_FAIL++ ))
|
||||
continue
|
||||
fi
|
||||
log " $host_var: resolved $hostname → $REMOTE_IP"
|
||||
|
||||
# Single SSH connection — fetch arrs + monitor stats together
|
||||
RESULT=$(ssh -i "$SSH_KEY" \
|
||||
-o ConnectTimeout=10 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
"root@${REMOTE_IP}" \
|
||||
"php -r \"
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/arrs.php';
|
||||
require_once '/usr/local/emhttp/plugins/varaverk/include/unraid_api.php';
|
||||
echo json_encode([
|
||||
'arrs' => vv_arrs_local_node(),
|
||||
'monitor' => vv_local_host_stats(),
|
||||
]);
|
||||
\"" 2>/dev/null)
|
||||
|
||||
if [[ -z "$RESULT" ]]; then
|
||||
warn " $host_var: empty SSH response — skipping"
|
||||
(( FETCH_FAIL++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validate combined JSON
|
||||
if ! echo "$RESULT" | php -r "
|
||||
\$d = json_decode(file_get_contents('php://stdin'), true);
|
||||
exit(!is_array(\$d) || !isset(\$d['arrs'], \$d['monitor']) ? 1 : 0);
|
||||
" 2>/dev/null; then
|
||||
warn " $host_var: invalid JSON — skipping"
|
||||
log " Response: ${RESULT:0:200}"
|
||||
(( FETCH_FAIL++ ))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Save arr cache
|
||||
echo "$RESULT" | php -r "
|
||||
\$d = json_decode(file_get_contents('php://stdin'), true);
|
||||
file_put_contents('$cache_file', json_encode(\$d['arrs']));
|
||||
" 2>/dev/null
|
||||
|
||||
# Save monitor cache
|
||||
MONITOR_CACHE="/tmp/vv_cache/monitor_remote_${host_id}.json"
|
||||
echo "$RESULT" | php -r "
|
||||
\$d = json_decode(file_get_contents('php://stdin'), true);
|
||||
file_put_contents('$MONITOR_CACHE', json_encode(\$d['monitor']));
|
||||
" 2>/dev/null
|
||||
|
||||
CACHED_TYPES=$(echo "$RESULT" | php -r "
|
||||
\$d = json_decode(file_get_contents('php://stdin'), true);
|
||||
echo implode(', ', array_column(\$d['arrs']['arrs'] ?? [], 'type'));
|
||||
" 2>/dev/null)
|
||||
echo " $host_var: arrs [${CACHED_TYPES:-none}] + monitor stats cached ✅"
|
||||
(( FETCH_OK++ ))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "━━━━━ $ICON_SUMMARY Cache Writer Summary ━━━━━"
|
||||
echo " $FETCH_OK updated · $FETCH_FAIL failed · $FETCH_SKIP skipped"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
@@ -0,0 +1,368 @@
|
||||
<?php
|
||||
// Config file parser and writer.
|
||||
// Reads master.conf and the appropriate host*.conf based on running host.
|
||||
|
||||
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
|
||||
|
||||
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
|
||||
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
|
||||
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
|
||||
define('DATA_DIR', SCRIPTS_DIR . '/data');
|
||||
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
|
||||
define('LOG_DIR', '/var/log/varaverk');
|
||||
unset($_vv_cfg);
|
||||
|
||||
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
|
||||
|
||||
// Read the setup state file into a key=>value array.
|
||||
function vv_setup_state_read(): array {
|
||||
$out = [];
|
||||
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
|
||||
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
|
||||
if ($k !== '') $out[$k] = $v;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// Write the setup state file (creates or overwrites).
|
||||
function vv_setup_state_write(array $data): void {
|
||||
$content = '';
|
||||
foreach ($data as $k => $v) $content .= "$k=$v\n";
|
||||
file_put_contents(VV_SETUP_STATE_FILE, $content);
|
||||
}
|
||||
|
||||
// Push the setup state file to all remote hosts via scp.
|
||||
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
|
||||
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
|
||||
function vv_push_setup_state(): void {
|
||||
if (!file_exists(VV_SETUP_STATE_FILE)) return;
|
||||
$myHostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
||||
if (!$sshKey || !file_exists($sshKey)) return;
|
||||
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
$seen = [];
|
||||
foreach ($m[1] as $i => $hostKey) {
|
||||
$hostId = strtolower($hostKey);
|
||||
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
||||
$seen[$hostId] = true;
|
||||
$hostname = trim($m[2][$i]);
|
||||
if (!$hostname) continue;
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) continue;
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
// Ensure the target dir exists (it always should on Unraid, but be safe)
|
||||
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
|
||||
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
|
||||
exec('scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
|
||||
}
|
||||
}
|
||||
|
||||
// Push master.conf to all remote hosts via scp after a local save.
|
||||
// Returns one result entry per remote found in master.conf.
|
||||
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
|
||||
function vv_push_master_conf(): array {
|
||||
$myHostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
||||
if (!$sshKey || !file_exists($sshKey)) return [];
|
||||
|
||||
$localPath = CONF_DIR . '/master.conf';
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
|
||||
$results = [];
|
||||
$seen = [];
|
||||
foreach ($m[1] as $i => $hostKey) {
|
||||
$hostId = strtolower($hostKey);
|
||||
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
||||
$seen[$hostId] = true;
|
||||
|
||||
$hostname = trim($m[2][$i]);
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) {
|
||||
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
|
||||
// Configurations/ exists, and master.conf is already present.
|
||||
// Any missing piece means the remote isn't ready — skip rather than push blind.
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$probe = trim(shell_exec(
|
||||
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
|
||||
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
|
||||
. ' && test -d \"${sd}/Configurations\"'
|
||||
. ' && test -f \"${sd}/Configurations/master.conf\"'
|
||||
. ' && echo \"$sd\""'
|
||||
) ?: '');
|
||||
|
||||
if ($probe === '') {
|
||||
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
|
||||
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$remoteConf = rtrim($probe, '/') . '/Configurations';
|
||||
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
$results[] = [
|
||||
'host' => $hostKey,
|
||||
'ok' => $rc === 0,
|
||||
'error' => $rc !== 0 ? implode('; ', $out) : '',
|
||||
];
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
function vv_get_hostname(): string {
|
||||
return trim(shell_exec('hostname -s') ?: '');
|
||||
}
|
||||
|
||||
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
|
||||
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
|
||||
function vv_resolve_tailscale_ip(string $hostname): string {
|
||||
$h = strtolower($hostname);
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
|
||||
if ($ip) return $ip;
|
||||
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
|
||||
foreach (explode("\n", $out) as $line) {
|
||||
$cols = preg_split('/\s+/', trim($line));
|
||||
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function vv_detect_host(): string {
|
||||
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
|
||||
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
$hostname = vv_get_hostname();
|
||||
foreach ($m[1] as $i => $key) {
|
||||
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function vv_is_owner(): bool {
|
||||
return vv_detect_host() === 'host1';
|
||||
}
|
||||
|
||||
function vv_read_conf_raw(string $filename): string {
|
||||
$path = CONF_DIR . '/' . $filename;
|
||||
return file_exists($path) ? file_get_contents($path) : '';
|
||||
}
|
||||
|
||||
function vv_write_conf_raw(string $filename, string $content): bool {
|
||||
$path = CONF_DIR . '/' . $filename;
|
||||
$tmp = $path . '.vv.tmp';
|
||||
if (file_put_contents($tmp, $content) === false) return false;
|
||||
return rename($tmp, $path);
|
||||
}
|
||||
|
||||
function vv_get_conf_files(): array {
|
||||
// Returns conf files this host is allowed to view/edit
|
||||
$host = vv_detect_host();
|
||||
$files = [];
|
||||
if ($host === 'host1') {
|
||||
// Owner sees master.conf + their own host conf
|
||||
$files[] = 'master.conf';
|
||||
$files[] = 'host1.conf';
|
||||
} elseif (preg_match('/^host(\d+)$/', $host)) {
|
||||
// Any other numbered host sees only their own conf
|
||||
$files[] = $host . '.conf';
|
||||
} else {
|
||||
// Unknown host — show all for dev/debug
|
||||
foreach (glob(CONF_DIR . '/*.conf') as $f) {
|
||||
$files[] = basename($f);
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
// Parse conf into key=>value map for $VAR substitution in docs
|
||||
function vv_conf_vars(): array {
|
||||
$vars = [];
|
||||
$files = ['master.conf'];
|
||||
$host = vv_detect_host();
|
||||
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
|
||||
|
||||
foreach ($files as $f) {
|
||||
$raw = vv_read_conf_raw($f);
|
||||
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
|
||||
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
||||
foreach ($m[1] as $i => $key) {
|
||||
$vars[$key] = trim($m[2][$i]);
|
||||
}
|
||||
}
|
||||
return $vars;
|
||||
}
|
||||
|
||||
// Query the Unraid GraphQL API for a given host.
|
||||
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
|
||||
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
|
||||
// since vv_conf_vars() only loads the current host's conf file).
|
||||
// Returns the decoded 'data' object on success, null on any failure.
|
||||
// Debug log written to /tmp/vv_api_debug.json on failure.
|
||||
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
|
||||
$vars = vv_conf_vars();
|
||||
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
|
||||
if (!$key) return null;
|
||||
|
||||
$myHostId = vv_detect_host();
|
||||
if (strtolower($hostId) === strtolower($myHostId)) {
|
||||
$url = 'http://localhost/graphql';
|
||||
} else {
|
||||
$hostname = $vars[strtoupper($hostId)] ?? '';
|
||||
if (!$hostname) return null;
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) return null;
|
||||
$url = "http://{$ip}/graphql";
|
||||
}
|
||||
|
||||
$body = json_encode(['query' => $gql]);
|
||||
|
||||
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeoutSec,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
} else {
|
||||
// Fallback to file_get_contents if curl is unavailable.
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
|
||||
'content' => $body,
|
||||
'timeout' => $timeoutSec,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
$httpCode = $resp !== false ? 200 : 0;
|
||||
$curlErr = '';
|
||||
}
|
||||
|
||||
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
|
||||
@file_put_contents('/tmp/vv_api_debug.json', json_encode([
|
||||
'ts' => time(),
|
||||
'host' => $hostId,
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'curl_err' => $curlErr,
|
||||
'response' => substr((string)$resp, 0, 800),
|
||||
], JSON_PRETTY_PRINT));
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$resp, true);
|
||||
|
||||
// If the API returned GraphQL errors, log them for diagnosis.
|
||||
if (!empty($decoded['errors'])) {
|
||||
@file_put_contents('/tmp/vv_api_debug.json', json_encode([
|
||||
'ts' => time(),
|
||||
'host' => $hostId,
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'errors' => $decoded['errors'],
|
||||
'data' => $decoded['data'] ?? null,
|
||||
], JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
// data key present (even if null means query ran but returned nothing useful).
|
||||
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
|
||||
}
|
||||
|
||||
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
|
||||
|
||||
define('VV_CACHE_DIR', '/tmp/vv_cache');
|
||||
|
||||
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
|
||||
function vv_cache_read(string $key, int $maxAge = 90): ?array {
|
||||
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
||||
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
|
||||
$raw = file_get_contents($f);
|
||||
return $raw ? (json_decode($raw, true) ?: null) : null;
|
||||
}
|
||||
|
||||
// Write a payload atomically (tmp + rename) so readers never see a partial file.
|
||||
function vv_cache_write(string $key, array $data): void {
|
||||
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
|
||||
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
||||
$tmp = $f . '.tmp';
|
||||
file_put_contents($tmp, json_encode($data));
|
||||
rename($tmp, $f);
|
||||
}
|
||||
|
||||
// ── Shared utility functions (used across include/ and api/ files) ────────────
|
||||
|
||||
// Format seconds into "2d 3h 15m".
|
||||
function vv_format_uptime(int $seconds): string {
|
||||
$d = intdiv($seconds, 86400);
|
||||
$h = intdiv($seconds % 86400, 3600);
|
||||
$m = intdiv($seconds % 3600, 60);
|
||||
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
|
||||
}
|
||||
|
||||
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
|
||||
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
|
||||
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
|
||||
function vv_parse_conf_scalar(string $raw, string $key): string {
|
||||
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
|
||||
? trim($m[1]) : '';
|
||||
}
|
||||
|
||||
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
|
||||
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
|
||||
function vv_parse_kv_db(string $text): array {
|
||||
$out = [];
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
|
||||
if ($k !== '') $out[trim($k)] = trim($v);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
|
||||
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
|
||||
function vv_known_hosts(): array {
|
||||
$vars = vv_conf_vars();
|
||||
$hosts = [];
|
||||
foreach ($vars as $k => $v) {
|
||||
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
|
||||
$hosts['host' . $m[1]] = $v;
|
||||
}
|
||||
}
|
||||
ksort($hosts);
|
||||
return $hosts ?: ['host1' => 'HOST1'];
|
||||
}
|
||||
|
||||
// Local LAN IP via routing table — static-cached per request.
|
||||
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
|
||||
function vv_local_ip(): string {
|
||||
static $ip = null;
|
||||
if ($ip !== null) return $ip;
|
||||
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
|
||||
return $ip;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
// Config file parser and writer.
|
||||
// Reads master.conf and the appropriate host*.conf based on running host.
|
||||
|
||||
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
|
||||
|
||||
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
|
||||
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
|
||||
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
|
||||
define('DATA_DIR', SCRIPTS_DIR . '/data');
|
||||
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
|
||||
define('LOG_DIR', '/var/log/varaverk');
|
||||
unset($_vv_cfg);
|
||||
|
||||
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
|
||||
define('VV_CACHE_DIR', '/tmp/vv_cache');
|
||||
|
||||
// Read the setup state file into a key=>value array.
|
||||
function vv_setup_state_read(): array {
|
||||
$out = [];
|
||||
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
|
||||
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
|
||||
if ($k !== '') $out[$k] = $v;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// Write the setup state file (creates or overwrites).
|
||||
function vv_setup_state_write(array $data): void {
|
||||
$content = '';
|
||||
foreach ($data as $k => $v) $content .= "$k=$v\n";
|
||||
file_put_contents(VV_SETUP_STATE_FILE, $content);
|
||||
}
|
||||
|
||||
// Push the setup state file to all remote hosts via scp.
|
||||
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
|
||||
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
|
||||
function vv_push_setup_state(): void {
|
||||
if (!file_exists(VV_SETUP_STATE_FILE)) return;
|
||||
$myHostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
||||
if (!$sshKey || !file_exists($sshKey)) return;
|
||||
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
$seen = [];
|
||||
foreach ($m[1] as $i => $hostKey) {
|
||||
$hostId = strtolower($hostKey);
|
||||
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
||||
$seen[$hostId] = true;
|
||||
$hostname = trim($m[2][$i]);
|
||||
if (!$hostname) continue;
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) continue;
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
// Ensure the target dir exists (it always should on Unraid, but be safe)
|
||||
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
|
||||
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
|
||||
exec('scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
|
||||
}
|
||||
}
|
||||
|
||||
// Push master.conf to all remote hosts via scp after a local save.
|
||||
// Returns one result entry per remote found in master.conf.
|
||||
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
|
||||
function vv_push_master_conf(): array {
|
||||
$myHostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
||||
if (!$sshKey || !file_exists($sshKey)) return [];
|
||||
|
||||
$localPath = CONF_DIR . '/master.conf';
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
|
||||
$results = [];
|
||||
$seen = [];
|
||||
foreach ($m[1] as $i => $hostKey) {
|
||||
$hostId = strtolower($hostKey);
|
||||
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
||||
$seen[$hostId] = true;
|
||||
|
||||
$hostname = trim($m[2][$i]);
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) {
|
||||
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
|
||||
// Configurations/ exists, and master.conf is already present.
|
||||
// Any missing piece means the remote isn't ready — skip rather than push blind.
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$probe = trim(shell_exec(
|
||||
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
|
||||
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
|
||||
. ' && test -d \"${sd}/Configurations\"'
|
||||
. ' && test -f \"${sd}/Configurations/master.conf\"'
|
||||
. ' && echo \"$sd\""'
|
||||
) ?: '');
|
||||
|
||||
if ($probe === '') {
|
||||
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
|
||||
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$remoteConf = rtrim($probe, '/') . '/Configurations';
|
||||
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
$results[] = [
|
||||
'host' => $hostKey,
|
||||
'ok' => $rc === 0,
|
||||
'error' => $rc !== 0 ? implode('; ', $out) : '',
|
||||
];
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
function vv_get_hostname(): string {
|
||||
return trim(shell_exec('hostname -s') ?: '');
|
||||
}
|
||||
|
||||
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
|
||||
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
|
||||
function vv_resolve_tailscale_ip(string $hostname): string {
|
||||
$h = strtolower($hostname);
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
|
||||
if ($ip) return $ip;
|
||||
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
|
||||
foreach (explode("\n", $out) as $line) {
|
||||
$cols = preg_split('/\s+/', trim($line));
|
||||
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function vv_detect_host(): string {
|
||||
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
|
||||
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
$hostname = vv_get_hostname();
|
||||
foreach ($m[1] as $i => $key) {
|
||||
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function vv_is_owner(): bool {
|
||||
return vv_detect_host() === 'host1';
|
||||
}
|
||||
|
||||
function vv_read_conf_raw(string $filename): string {
|
||||
$path = CONF_DIR . '/' . $filename;
|
||||
return file_exists($path) ? file_get_contents($path) : '';
|
||||
}
|
||||
|
||||
function vv_write_conf_raw(string $filename, string $content): bool {
|
||||
$path = CONF_DIR . '/' . $filename;
|
||||
$tmp = $path . '.vv.tmp';
|
||||
if (file_put_contents($tmp, $content) === false) return false;
|
||||
return rename($tmp, $path);
|
||||
}
|
||||
|
||||
function vv_get_conf_files(): array {
|
||||
// Returns conf files this host is allowed to view/edit
|
||||
$host = vv_detect_host();
|
||||
$files = [];
|
||||
if ($host === 'host1') {
|
||||
// Owner sees master.conf + their own host conf
|
||||
$files[] = 'master.conf';
|
||||
$files[] = 'host1.conf';
|
||||
} elseif (preg_match('/^host(\d+)$/', $host)) {
|
||||
// Any other numbered host sees only their own conf
|
||||
$files[] = $host . '.conf';
|
||||
} else {
|
||||
// Unknown host — show all for dev/debug
|
||||
foreach (glob(CONF_DIR . '/*.conf') as $f) {
|
||||
$files[] = basename($f);
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
// Parse conf into key=>value map for $VAR substitution in docs
|
||||
function vv_conf_vars(): array {
|
||||
$vars = [];
|
||||
$files = ['master.conf'];
|
||||
$host = vv_detect_host();
|
||||
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
|
||||
|
||||
foreach ($files as $f) {
|
||||
$raw = vv_read_conf_raw($f);
|
||||
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
|
||||
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
||||
foreach ($m[1] as $i => $key) {
|
||||
$vars[$key] = trim($m[2][$i]);
|
||||
}
|
||||
}
|
||||
return $vars;
|
||||
}
|
||||
|
||||
// Query the Unraid GraphQL API for a given host.
|
||||
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
|
||||
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
|
||||
// since vv_conf_vars() only loads the current host's conf file).
|
||||
// Returns the decoded 'data' object on success, null on any failure.
|
||||
// Debug log written to /tmp/vv_api_debug.json on failure.
|
||||
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
|
||||
$vars = vv_conf_vars();
|
||||
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
|
||||
if (!$key) return null;
|
||||
|
||||
$myHostId = vv_detect_host();
|
||||
if (strtolower($hostId) === strtolower($myHostId)) {
|
||||
$url = 'http://localhost/graphql';
|
||||
} else {
|
||||
$hostname = $vars[strtoupper($hostId)] ?? '';
|
||||
if (!$hostname) return null;
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) return null;
|
||||
$url = "http://{$ip}/graphql";
|
||||
}
|
||||
|
||||
$body = json_encode(['query' => $gql]);
|
||||
|
||||
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeoutSec,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
} else {
|
||||
// Fallback to file_get_contents if curl is unavailable.
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
|
||||
'content' => $body,
|
||||
'timeout' => $timeoutSec,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
$httpCode = $resp !== false ? 200 : 0;
|
||||
$curlErr = '';
|
||||
}
|
||||
|
||||
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
|
||||
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
|
||||
'ts' => time(),
|
||||
'host' => $hostId,
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'curl_err' => $curlErr,
|
||||
'response' => substr((string)$resp, 0, 800),
|
||||
], JSON_PRETTY_PRINT));
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$resp, true);
|
||||
|
||||
// If the API returned GraphQL errors, log them for diagnosis.
|
||||
if (!empty($decoded['errors'])) {
|
||||
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
|
||||
'ts' => time(),
|
||||
'host' => $hostId,
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'errors' => $decoded['errors'],
|
||||
'data' => $decoded['data'] ?? null,
|
||||
], JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
// data key present (even if null means query ran but returned nothing useful).
|
||||
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
|
||||
}
|
||||
|
||||
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
|
||||
|
||||
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
|
||||
function vv_cache_read(string $key, int $maxAge = 90): ?array {
|
||||
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
||||
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
|
||||
$raw = file_get_contents($f);
|
||||
return $raw ? (json_decode($raw, true) ?: null) : null;
|
||||
}
|
||||
|
||||
// Write a payload atomically (tmp + rename) so readers never see a partial file.
|
||||
function vv_cache_write(string $key, array $data): void {
|
||||
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
|
||||
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
||||
$tmp = $f . '.tmp';
|
||||
file_put_contents($tmp, json_encode($data));
|
||||
rename($tmp, $f);
|
||||
}
|
||||
|
||||
// ── Shared utility functions (used across include/ and api/ files) ────────────
|
||||
|
||||
// Format seconds into "2d 3h 15m".
|
||||
function vv_format_uptime(int $seconds): string {
|
||||
$d = intdiv($seconds, 86400);
|
||||
$h = intdiv($seconds % 86400, 3600);
|
||||
$m = intdiv($seconds % 3600, 60);
|
||||
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
|
||||
}
|
||||
|
||||
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
|
||||
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
|
||||
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
|
||||
function vv_parse_conf_scalar(string $raw, string $key): string {
|
||||
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
|
||||
? trim($m[1]) : '';
|
||||
}
|
||||
|
||||
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
|
||||
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
|
||||
function vv_parse_kv_db(string $text): array {
|
||||
$out = [];
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
|
||||
if ($k !== '') $out[trim($k)] = trim($v);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
|
||||
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
|
||||
function vv_known_hosts(): array {
|
||||
$vars = vv_conf_vars();
|
||||
$hosts = [];
|
||||
foreach ($vars as $k => $v) {
|
||||
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
|
||||
$hosts['host' . $m[1]] = $v;
|
||||
}
|
||||
}
|
||||
ksort($hosts);
|
||||
return $hosts ?: ['host1' => 'HOST1'];
|
||||
}
|
||||
|
||||
// Local LAN IP via routing table — static-cached per request.
|
||||
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
|
||||
function vv_local_ip(): string {
|
||||
static $ip = null;
|
||||
if ($ip !== null) return $ip;
|
||||
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
|
||||
return $ip;
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
<?php
|
||||
// Config file parser and writer.
|
||||
// Reads master.conf and the appropriate host*.conf based on running host.
|
||||
|
||||
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
|
||||
|
||||
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
|
||||
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
|
||||
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
|
||||
define('DATA_DIR', SCRIPTS_DIR . '/data');
|
||||
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
|
||||
define('LOG_DIR', '/var/log/varaverk');
|
||||
unset($_vv_cfg);
|
||||
|
||||
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
|
||||
define('VV_CACHE_DIR', '/tmp/vv_cache');
|
||||
|
||||
// Read the setup state file into a key=>value array.
|
||||
function vv_setup_state_read(): array {
|
||||
$out = [];
|
||||
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
|
||||
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
|
||||
if ($k !== '') $out[$k] = $v;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// Write the setup state file (creates or overwrites).
|
||||
function vv_setup_state_write(array $data): void {
|
||||
$content = '';
|
||||
foreach ($data as $k => $v) $content .= "$k=$v\n";
|
||||
file_put_contents(VV_SETUP_STATE_FILE, $content);
|
||||
}
|
||||
|
||||
// Push the setup state file to all remote hosts via scp.
|
||||
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
|
||||
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
|
||||
function vv_push_setup_state(): void {
|
||||
if (!file_exists(VV_SETUP_STATE_FILE)) return;
|
||||
$myHostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
||||
if (!$sshKey || !file_exists($sshKey)) return;
|
||||
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
$seen = [];
|
||||
foreach ($m[1] as $i => $hostKey) {
|
||||
$hostId = strtolower($hostKey);
|
||||
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
||||
$seen[$hostId] = true;
|
||||
$hostname = trim($m[2][$i]);
|
||||
if (!$hostname) continue;
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) continue;
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
// Ensure the target dir exists (it always should on Unraid, but be safe)
|
||||
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
|
||||
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
|
||||
exec('scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
|
||||
}
|
||||
}
|
||||
|
||||
// Push master.conf to all remote hosts via scp after a local save.
|
||||
// Returns one result entry per remote found in master.conf.
|
||||
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
|
||||
function vv_push_master_conf(): array {
|
||||
$myHostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
||||
if (!$sshKey || !file_exists($sshKey)) return [];
|
||||
|
||||
$localPath = CONF_DIR . '/master.conf';
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
|
||||
$results = [];
|
||||
$seen = [];
|
||||
foreach ($m[1] as $i => $hostKey) {
|
||||
$hostId = strtolower($hostKey);
|
||||
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
||||
$seen[$hostId] = true;
|
||||
|
||||
$hostname = trim($m[2][$i]);
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) {
|
||||
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
|
||||
// Configurations/ exists, and master.conf is already present.
|
||||
// Any missing piece means the remote isn't ready — skip rather than push blind.
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$probe = trim(shell_exec(
|
||||
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
|
||||
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
|
||||
. ' && test -d \"${sd}/Configurations\"'
|
||||
. ' && test -f \"${sd}/Configurations/master.conf\"'
|
||||
. ' && echo \"$sd\""'
|
||||
) ?: '');
|
||||
|
||||
if ($probe === '') {
|
||||
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
|
||||
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$remoteConf = rtrim($probe, '/') . '/Configurations';
|
||||
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
$results[] = [
|
||||
'host' => $hostKey,
|
||||
'ok' => $rc === 0,
|
||||
'error' => $rc !== 0 ? implode('; ', $out) : '',
|
||||
];
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
function vv_get_hostname(): string {
|
||||
return trim(shell_exec('hostname -s') ?: '');
|
||||
}
|
||||
|
||||
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
|
||||
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
|
||||
function vv_resolve_tailscale_ip(string $hostname): string {
|
||||
$h = strtolower($hostname);
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
|
||||
if ($ip) return $ip;
|
||||
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
|
||||
foreach (explode("\n", $out) as $line) {
|
||||
$cols = preg_split('/\s+/', trim($line));
|
||||
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function vv_detect_host(): string {
|
||||
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
|
||||
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
$hostname = vv_get_hostname();
|
||||
foreach ($m[1] as $i => $key) {
|
||||
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function vv_is_owner(): bool {
|
||||
return vv_detect_host() === 'host1';
|
||||
}
|
||||
|
||||
function vv_read_conf_raw(string $filename): string {
|
||||
$path = CONF_DIR . '/' . $filename;
|
||||
return file_exists($path) ? file_get_contents($path) : '';
|
||||
}
|
||||
|
||||
function vv_write_conf_raw(string $filename, string $content): bool {
|
||||
$path = CONF_DIR . '/' . $filename;
|
||||
$tmp = $path . '.vv.tmp';
|
||||
if (file_put_contents($tmp, $content) === false) return false;
|
||||
return rename($tmp, $path);
|
||||
}
|
||||
|
||||
function vv_get_conf_files(): array {
|
||||
// Returns conf files this host is allowed to view/edit
|
||||
$host = vv_detect_host();
|
||||
$files = [];
|
||||
if ($host === 'host1') {
|
||||
// Owner sees master.conf + their own host conf
|
||||
$files[] = 'master.conf';
|
||||
$files[] = 'host1.conf';
|
||||
} elseif (preg_match('/^host(\d+)$/', $host)) {
|
||||
// Any other numbered host sees only their own conf
|
||||
$files[] = $host . '.conf';
|
||||
} else {
|
||||
// Unknown host — show all for dev/debug
|
||||
foreach (glob(CONF_DIR . '/*.conf') as $f) {
|
||||
$files[] = basename($f);
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
// Parse conf into key=>value map for $VAR substitution in docs
|
||||
function vv_conf_vars(): array {
|
||||
$vars = [];
|
||||
$files = ['master.conf'];
|
||||
$host = vv_detect_host();
|
||||
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
|
||||
|
||||
foreach ($files as $f) {
|
||||
$raw = vv_read_conf_raw($f);
|
||||
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
|
||||
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
||||
foreach ($m[1] as $i => $key) {
|
||||
$vars[$key] = trim($m[2][$i]);
|
||||
}
|
||||
}
|
||||
return $vars;
|
||||
}
|
||||
|
||||
// Query the Unraid GraphQL API for a given host.
|
||||
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
|
||||
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
|
||||
// since vv_conf_vars() only loads the current host's conf file).
|
||||
// Returns the decoded 'data' object on success, null on any failure.
|
||||
// Debug log written to /tmp/vv_api_debug.json on failure.
|
||||
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
|
||||
$vars = vv_conf_vars();
|
||||
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
|
||||
if (!$key) return null;
|
||||
|
||||
$myHostId = vv_detect_host();
|
||||
if (strtolower($hostId) === strtolower($myHostId)) {
|
||||
$url = 'http://localhost/graphql';
|
||||
} else {
|
||||
$hostname = $vars[strtoupper($hostId)] ?? '';
|
||||
if (!$hostname) return null;
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) return null;
|
||||
$url = "http://{$ip}/graphql";
|
||||
}
|
||||
|
||||
$body = json_encode(['query' => $gql]);
|
||||
|
||||
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeoutSec,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
} else {
|
||||
// Fallback to file_get_contents if curl is unavailable.
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
|
||||
'content' => $body,
|
||||
'timeout' => $timeoutSec,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
$httpCode = $resp !== false ? 200 : 0;
|
||||
$curlErr = '';
|
||||
}
|
||||
|
||||
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
|
||||
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
|
||||
'ts' => time(),
|
||||
'host' => $hostId,
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'curl_err' => $curlErr,
|
||||
'response' => substr((string)$resp, 0, 800),
|
||||
], JSON_PRETTY_PRINT));
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$resp, true);
|
||||
|
||||
// If the API returned GraphQL errors, log them for diagnosis.
|
||||
if (!empty($decoded['errors'])) {
|
||||
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
|
||||
'ts' => time(),
|
||||
'host' => $hostId,
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'errors' => $decoded['errors'],
|
||||
'data' => $decoded['data'] ?? null,
|
||||
], JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
// data key present (even if null means query ran but returned nothing useful).
|
||||
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
|
||||
}
|
||||
|
||||
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
|
||||
|
||||
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
|
||||
function vv_cache_read(string $key, int $maxAge = 90): ?array {
|
||||
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
||||
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
|
||||
$raw = file_get_contents($f);
|
||||
return $raw ? (json_decode($raw, true) ?: null) : null;
|
||||
}
|
||||
|
||||
// Write a payload atomically (tmp + rename) so readers never see a partial file.
|
||||
function vv_cache_write(string $key, array $data): void {
|
||||
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
|
||||
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
||||
$tmp = $f . '.tmp';
|
||||
file_put_contents($tmp, json_encode($data));
|
||||
rename($tmp, $f);
|
||||
}
|
||||
|
||||
// ── Shared utility functions (used across include/ and api/ files) ────────────
|
||||
|
||||
// Format seconds into "2d 3h 15m".
|
||||
function vv_format_uptime(int $seconds): string {
|
||||
$d = intdiv($seconds, 86400);
|
||||
$h = intdiv($seconds % 86400, 3600);
|
||||
$m = intdiv($seconds % 3600, 60);
|
||||
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
|
||||
}
|
||||
|
||||
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
|
||||
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
|
||||
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
|
||||
function vv_parse_conf_scalar(string $raw, string $key): string {
|
||||
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
|
||||
? trim($m[1]) : '';
|
||||
}
|
||||
|
||||
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
|
||||
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
|
||||
function vv_parse_kv_db(string $text): array {
|
||||
$out = [];
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
|
||||
if ($k !== '') $out[trim($k)] = trim($v);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
|
||||
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
|
||||
function vv_known_hosts(): array {
|
||||
$vars = vv_conf_vars();
|
||||
$hosts = [];
|
||||
foreach ($vars as $k => $v) {
|
||||
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
|
||||
$hosts['host' . $m[1]] = $v;
|
||||
}
|
||||
}
|
||||
ksort($hosts);
|
||||
return $hosts ?: ['host1' => 'HOST1'];
|
||||
}
|
||||
|
||||
// Create (or overwrite) the Varaverk Unraid API key and write it into host conf.
|
||||
// Returns ['ok'=>true,'key_preview'=>'...'] or ['ok'=>false,'error'=>'...'].
|
||||
function vv_auto_create_api_key(string $hostId, string $confFile): array {
|
||||
$varName = strtoupper($hostId) . '_UNRAID_API_KEY';
|
||||
$output = shell_exec('timeout 10 /usr/local/sbin/unraid-api apikey --name "Varaverk" --create --overwrite --description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1');
|
||||
if (!$output) {
|
||||
return ['ok' => false, 'error' => 'unraid-api returned no output'];
|
||||
}
|
||||
$data = json_decode(trim($output), true);
|
||||
$key = $data['key'] ?? null;
|
||||
if (!$key) {
|
||||
return ['ok' => false, 'error' => 'No key in response'];
|
||||
}
|
||||
$raw = vv_read_conf_raw($confFile);
|
||||
if ($raw === '') {
|
||||
return ['ok' => false, 'error' => 'Cannot read ' . $confFile];
|
||||
}
|
||||
if (!str_contains($raw, $varName)) {
|
||||
foreach ([strtoupper($hostId) . '_OWNER_EMAIL', strtoupper($hostId) . '_SSH_KEY'] as $anchor) {
|
||||
if (str_contains($raw, $anchor)) {
|
||||
$raw = preg_replace('/^(\s*' . preg_quote($anchor, '/') . '\s*=.*$)/m',
|
||||
'$1' . "\n " . $varName . '=""', $raw, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$raw = preg_replace('/^(\s*' . preg_quote($varName, '/') . '\s*=\s*)"[^"]*"/m',
|
||||
'${1}"' . $key . '"', $raw);
|
||||
vv_write_conf_raw($confFile, $raw);
|
||||
return ['ok' => true, 'key_preview' => substr($key, 0, 8) . '...' . substr($key, -4)];
|
||||
}
|
||||
|
||||
// Local LAN IP via routing table — static-cached per request.
|
||||
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
|
||||
function vv_local_ip(): string {
|
||||
static $ip = null;
|
||||
if ($ip !== null) return $ip;
|
||||
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
|
||||
return $ip;
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
<?php
|
||||
// Config file parser and writer.
|
||||
// Reads master.conf and the appropriate host*.conf based on running host.
|
||||
|
||||
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
|
||||
|
||||
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
|
||||
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
|
||||
define('CONF_DIR', SCRIPTS_DIR . '/Configurations');
|
||||
define('DATA_DIR', SCRIPTS_DIR . '/data');
|
||||
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
|
||||
define('LOG_DIR', '/var/log/varaverk');
|
||||
unset($_vv_cfg);
|
||||
|
||||
define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
|
||||
define('VV_CACHE_DIR', '/tmp/vv_cache');
|
||||
|
||||
// Read the setup state file into a key=>value array.
|
||||
function vv_setup_state_read(): array {
|
||||
$out = [];
|
||||
foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
|
||||
[$k, $v] = explode('=', trim($line), 2) + ['', ''];
|
||||
if ($k !== '') $out[$k] = $v;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// Write the setup state file (creates or overwrites).
|
||||
function vv_setup_state_write(array $data): void {
|
||||
$content = '';
|
||||
foreach ($data as $k => $v) $content .= "$k=$v\n";
|
||||
file_put_contents(VV_SETUP_STATE_FILE, $content);
|
||||
}
|
||||
|
||||
// Push the setup state file to all remote hosts via scp.
|
||||
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
|
||||
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
|
||||
function vv_push_setup_state(): void {
|
||||
if (!file_exists(VV_SETUP_STATE_FILE)) return;
|
||||
$myHostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
||||
if (!$sshKey || !file_exists($sshKey)) return;
|
||||
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
$seen = [];
|
||||
foreach ($m[1] as $i => $hostKey) {
|
||||
$hostId = strtolower($hostKey);
|
||||
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
||||
$seen[$hostId] = true;
|
||||
$hostname = trim($m[2][$i]);
|
||||
if (!$hostname) continue;
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) continue;
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
// Ensure the target dir exists (it always should on Unraid, but be safe)
|
||||
shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
|
||||
$dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
|
||||
exec('scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
|
||||
}
|
||||
}
|
||||
|
||||
// Push master.conf to all remote hosts via scp after a local save.
|
||||
// Returns one result entry per remote found in master.conf.
|
||||
// Silently returns [] on non-owner hosts (no SSH key, no remote access).
|
||||
function vv_push_master_conf(): array {
|
||||
$myHostId = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
|
||||
if (!$sshKey || !file_exists($sshKey)) return [];
|
||||
|
||||
$localPath = CONF_DIR . '/master.conf';
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
|
||||
$results = [];
|
||||
$seen = [];
|
||||
foreach ($m[1] as $i => $hostKey) {
|
||||
$hostId = strtolower($hostKey);
|
||||
if ($hostId === $myHostId || isset($seen[$hostId])) continue;
|
||||
$seen[$hostId] = true;
|
||||
|
||||
$hostname = trim($m[2][$i]);
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) {
|
||||
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
|
||||
// Configurations/ exists, and master.conf is already present.
|
||||
// Any missing piece means the remote isn't ready — skip rather than push blind.
|
||||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
|
||||
$probe = trim(shell_exec(
|
||||
$sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
|
||||
. ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
|
||||
. ' && test -d \"${sd}/Configurations\"'
|
||||
. ' && test -f \"${sd}/Configurations/master.conf\"'
|
||||
. ' && echo \"$sd\""'
|
||||
) ?: '');
|
||||
|
||||
if ($probe === '') {
|
||||
$results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
|
||||
'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$remoteConf = rtrim($probe, '/') . '/Configurations';
|
||||
$dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
|
||||
$cmd = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
. ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
|
||||
exec($cmd, $out, $rc);
|
||||
$results[] = [
|
||||
'host' => $hostKey,
|
||||
'ok' => $rc === 0,
|
||||
'error' => $rc !== 0 ? implode('; ', $out) : '',
|
||||
];
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
function vv_get_hostname(): string {
|
||||
return trim(shell_exec('hostname -s') ?: '');
|
||||
}
|
||||
|
||||
// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
|
||||
// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
|
||||
function vv_resolve_tailscale_ip(string $hostname): string {
|
||||
$h = strtolower($hostname);
|
||||
$ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
|
||||
if ($ip) return $ip;
|
||||
$out = shell_exec('tailscale status 2>/dev/null') ?: '';
|
||||
foreach (explode("\n", $out) as $line) {
|
||||
$cols = preg_split('/\s+/', trim($line));
|
||||
if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function vv_detect_host(): string {
|
||||
// Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
|
||||
// Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
|
||||
$hostname = vv_get_hostname();
|
||||
foreach ($m[1] as $i => $key) {
|
||||
if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function vv_is_owner(): bool {
|
||||
return vv_detect_host() === 'host1';
|
||||
}
|
||||
|
||||
function vv_read_conf_raw(string $filename): string {
|
||||
$path = CONF_DIR . '/' . $filename;
|
||||
return file_exists($path) ? file_get_contents($path) : '';
|
||||
}
|
||||
|
||||
function vv_write_conf_raw(string $filename, string $content): bool {
|
||||
$path = CONF_DIR . '/' . $filename;
|
||||
$tmp = $path . '.vv.tmp';
|
||||
if (file_put_contents($tmp, $content) === false) return false;
|
||||
return rename($tmp, $path);
|
||||
}
|
||||
|
||||
function vv_get_conf_files(): array {
|
||||
// Returns conf files this host is allowed to view/edit
|
||||
$host = vv_detect_host();
|
||||
$files = [];
|
||||
if ($host === 'host1') {
|
||||
// Owner sees master.conf + their own host conf
|
||||
$files[] = 'master.conf';
|
||||
$files[] = 'host1.conf';
|
||||
} elseif (preg_match('/^host(\d+)$/', $host)) {
|
||||
// Any other numbered host sees only their own conf
|
||||
$files[] = $host . '.conf';
|
||||
} else {
|
||||
// Unknown host — show all for dev/debug
|
||||
foreach (glob(CONF_DIR . '/*.conf') as $f) {
|
||||
$files[] = basename($f);
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
// Parse conf into key=>value map for $VAR substitution in docs
|
||||
function vv_conf_vars(): array {
|
||||
$vars = [];
|
||||
$files = ['master.conf'];
|
||||
$host = vv_detect_host();
|
||||
if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
|
||||
|
||||
foreach ($files as $f) {
|
||||
$raw = vv_read_conf_raw($f);
|
||||
// Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
|
||||
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
||||
foreach ($m[1] as $i => $key) {
|
||||
$vars[$key] = str_replace('\\$', '$', trim($m[2][$i]));
|
||||
}
|
||||
}
|
||||
return $vars;
|
||||
}
|
||||
|
||||
// Query the Unraid GraphQL API for a given host.
|
||||
// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
|
||||
// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
|
||||
// since vv_conf_vars() only loads the current host's conf file).
|
||||
// Returns the decoded 'data' object on success, null on any failure.
|
||||
// Debug log written to /tmp/vv_api_debug.json on failure.
|
||||
function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
|
||||
$vars = vv_conf_vars();
|
||||
$key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
|
||||
if (!$key) return null;
|
||||
|
||||
$myHostId = vv_detect_host();
|
||||
if (strtolower($hostId) === strtolower($myHostId)) {
|
||||
$url = 'http://localhost/graphql';
|
||||
} else {
|
||||
$hostname = $vars[strtoupper($hostId)] ?? '';
|
||||
if (!$hostname) return null;
|
||||
$ip = vv_resolve_tailscale_ip($hostname);
|
||||
if (!$ip) return null;
|
||||
$url = "http://{$ip}/graphql";
|
||||
}
|
||||
|
||||
$body = json_encode(['query' => $gql]);
|
||||
|
||||
// Use curl (preferred — doesn't require allow_url_fopen, better error handling).
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeoutSec,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
} else {
|
||||
// Fallback to file_get_contents if curl is unavailable.
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
|
||||
'content' => $body,
|
||||
'timeout' => $timeoutSec,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
$httpCode = $resp !== false ? 200 : 0;
|
||||
$curlErr = '';
|
||||
}
|
||||
|
||||
if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
|
||||
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
|
||||
'ts' => time(),
|
||||
'host' => $hostId,
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'curl_err' => $curlErr,
|
||||
'response' => substr((string)$resp, 0, 800),
|
||||
], JSON_PRETTY_PRINT));
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$resp, true);
|
||||
|
||||
// If the API returned GraphQL errors, log them for diagnosis.
|
||||
if (!empty($decoded['errors'])) {
|
||||
@file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
|
||||
'ts' => time(),
|
||||
'host' => $hostId,
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'errors' => $decoded['errors'],
|
||||
'data' => $decoded['data'] ?? null,
|
||||
], JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
// data key present (even if null means query ran but returned nothing useful).
|
||||
return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
|
||||
}
|
||||
|
||||
// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
|
||||
|
||||
// Read a cached payload. Returns null if missing or older than $maxAge seconds.
|
||||
function vv_cache_read(string $key, int $maxAge = 90): ?array {
|
||||
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
||||
if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
|
||||
$raw = file_get_contents($f);
|
||||
return $raw ? (json_decode($raw, true) ?: null) : null;
|
||||
}
|
||||
|
||||
// Write a payload atomically (tmp + rename) so readers never see a partial file.
|
||||
function vv_cache_write(string $key, array $data): void {
|
||||
if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
|
||||
$f = VV_CACHE_DIR . '/' . $key . '.json';
|
||||
$tmp = $f . '.tmp';
|
||||
file_put_contents($tmp, json_encode($data));
|
||||
rename($tmp, $f);
|
||||
}
|
||||
|
||||
// ── Shared utility functions (used across include/ and api/ files) ────────────
|
||||
|
||||
// Format seconds into "2d 3h 15m".
|
||||
function vv_format_uptime(int $seconds): string {
|
||||
$d = intdiv($seconds, 86400);
|
||||
$h = intdiv($seconds % 86400, 3600);
|
||||
$m = intdiv($seconds % 3600, 60);
|
||||
return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
|
||||
}
|
||||
|
||||
// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
|
||||
// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
|
||||
// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
|
||||
function vv_parse_conf_scalar(string $raw, string $key): string {
|
||||
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
|
||||
? trim($m[1]) : '';
|
||||
}
|
||||
|
||||
// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
|
||||
// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
|
||||
function vv_parse_kv_db(string $text): array {
|
||||
$out = [];
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
|
||||
if ($k !== '') $out[trim($k)] = trim($v);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// All configured hosts from master.conf as ['host1' => 'hostname', ...].
|
||||
// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
|
||||
function vv_known_hosts(): array {
|
||||
$vars = vv_conf_vars();
|
||||
$hosts = [];
|
||||
foreach ($vars as $k => $v) {
|
||||
if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
|
||||
$hosts['host' . $m[1]] = $v;
|
||||
}
|
||||
}
|
||||
ksort($hosts);
|
||||
return $hosts ?: ['host1' => 'HOST1'];
|
||||
}
|
||||
|
||||
// Create (or overwrite) the Varaverk Unraid API key and write it into host conf.
|
||||
// Returns ['ok'=>true,'key_preview'=>'...'] or ['ok'=>false,'error'=>'...'].
|
||||
function vv_auto_create_api_key(string $hostId, string $confFile): array {
|
||||
$varName = strtoupper($hostId) . '_UNRAID_API_KEY';
|
||||
$output = shell_exec('timeout 10 /usr/local/sbin/unraid-api apikey --name "Varaverk" --create --overwrite --description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1');
|
||||
if (!$output) {
|
||||
return ['ok' => false, 'error' => 'unraid-api returned no output'];
|
||||
}
|
||||
$data = json_decode(trim($output), true);
|
||||
$key = $data['key'] ?? null;
|
||||
if (!$key) {
|
||||
return ['ok' => false, 'error' => 'No key in response'];
|
||||
}
|
||||
$raw = vv_read_conf_raw($confFile);
|
||||
if ($raw === '') {
|
||||
return ['ok' => false, 'error' => 'Cannot read ' . $confFile];
|
||||
}
|
||||
if (!str_contains($raw, $varName)) {
|
||||
foreach ([strtoupper($hostId) . '_OWNER_EMAIL', strtoupper($hostId) . '_SSH_KEY'] as $anchor) {
|
||||
if (str_contains($raw, $anchor)) {
|
||||
$raw = preg_replace('/^(\s*' . preg_quote($anchor, '/') . '\s*=.*$)/m',
|
||||
'$1' . "\n " . $varName . '=""', $raw, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$raw = preg_replace('/^(\s*' . preg_quote($varName, '/') . '\s*=\s*)"[^"]*"/m',
|
||||
'${1}"' . $key . '"', $raw);
|
||||
vv_write_conf_raw($confFile, $raw);
|
||||
return ['ok' => true, 'key_preview' => substr($key, 0, 8) . '...' . substr($key, -4)];
|
||||
}
|
||||
|
||||
// Local LAN IP via routing table — static-cached per request.
|
||||
// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
|
||||
function vv_local_ip(): string {
|
||||
static $ip = null;
|
||||
if ($ip !== null) return $ip;
|
||||
$ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? '');
|
||||
return $ip;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||
|
||||
// ── Boot device detection ─────────────────────────────────────────────────────
|
||||
function vv_storage_detect_transport(): string {
|
||||
$part = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
|
||||
if (!$part) return 'unknown';
|
||||
$disk = trim(shell_exec("lsblk -no pkname " . escapeshellarg($part) . " 2>/dev/null") ?: '');
|
||||
if (!$disk) return 'unknown';
|
||||
return strtolower(trim(shell_exec("lsblk -dno TRAN /dev/" . escapeshellarg($disk) . " 2>/dev/null") ?: 'unknown'));
|
||||
}
|
||||
|
||||
// ── Current mode status ───────────────────────────────────────────────────────
|
||||
if ($action === 'status') {
|
||||
$transport = vv_storage_detect_transport();
|
||||
$detected = ($transport === 'usb') ? 'flash' : 'internal';
|
||||
$currentDir = SCRIPTS_DIR;
|
||||
$internalDir = '/boot/config/plugins/varaverk';
|
||||
$flashDir = '/mnt/user/appdata/Varaverk';
|
||||
$currentMode = ($currentDir === $internalDir) ? 'internal'
|
||||
: ($currentDir === $flashDir ? 'flash' : 'custom');
|
||||
|
||||
$myHost = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
|
||||
$confVal = $vars[$confKey] ?? null;
|
||||
|
||||
// Boot device name for display
|
||||
$bootPart = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
|
||||
$bootDisk = $bootPart ? trim(shell_exec("lsblk -no pkname " . escapeshellarg($bootPart) . " 2>/dev/null") ?: '') : '';
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'current_mode' => $currentMode,
|
||||
'current_dir' => $currentDir,
|
||||
'internal_dir' => $internalDir,
|
||||
'flash_dir' => $flashDir,
|
||||
'transport' => $transport,
|
||||
'detected' => $detected,
|
||||
'boot_disk' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
|
||||
'conf_key' => $confKey,
|
||||
'conf_val' => $confVal,
|
||||
'array_started'=> is_dir('/mnt/user') && count(scandir('/mnt/user')) > 2,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Run migration ─────────────────────────────────────────────────────────────
|
||||
if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$to = trim($_POST['to'] ?? '');
|
||||
if (!in_array($to, ['internal', 'flash'], true)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid target: must be internal or flash']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$script = SCRIPTS_DIR . '/Tools/storage_migrate.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
set_time_limit(300);
|
||||
$output = [];
|
||||
$exit = 0;
|
||||
exec('bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => $exit === 0,
|
||||
'exit' => $exit,
|
||||
'output' => implode("\n", $output),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Auto-detect and write to conf ─────────────────────────────────────────────
|
||||
if ($action === 'detect' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$transport = vv_storage_detect_transport();
|
||||
$detected = ($transport === 'usb') ? 'false' : 'true';
|
||||
$myHost = vv_detect_host();
|
||||
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
|
||||
$confFile = $myHost . '.conf';
|
||||
|
||||
$results = vv_conf_write_changes([[
|
||||
'file' => $confFile,
|
||||
'key' => $confKey,
|
||||
'value' => $detected,
|
||||
'type' => 'scalar',
|
||||
]]);
|
||||
|
||||
$ok = !in_array(false, $results, true);
|
||||
echo json_encode(['ok' => $ok, 'detected' => $detected, 'transport' => $transport]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Unraid API key status ─────────────────────────────────────────────────────
|
||||
// Keys live in host*.conf (private, not master.conf):
|
||||
// host1.conf: HOST1_UNRAID_API_KEY (own) + HOST2_UNRAID_API_KEY (HOST1's access to HOST2)
|
||||
// host2.conf: HOST2_UNRAID_API_KEY (own) + HOST1_UNRAID_API_KEY (HOST2's access to HOST1)
|
||||
if ($action === 'api_status') {
|
||||
require_once dirname(__DIR__) . '/include/unraid_api.php';
|
||||
$localStatus = vv_api_get_status();
|
||||
$vars = vv_conf_vars();
|
||||
$myHost = vv_detect_host();
|
||||
$myId = strtoupper($myHost);
|
||||
|
||||
$hosts = [];
|
||||
foreach ($vars as $k => $v) {
|
||||
if (!preg_match('/^HOST(\d+)$/', $k, $m) || !$v) continue;
|
||||
$id = 'HOST' . $m[1];
|
||||
$keyVar = $id . '_UNRAID_API_KEY';
|
||||
$key = $vars[$keyVar] ?? '';
|
||||
$isLocal = ($id === $myId);
|
||||
// For local: key is Varaverk_HOST1 registered on own machine
|
||||
// For remote: key is Varaverk_HOST1 registered on HOST2's machine (stored in host1.conf)
|
||||
$hosts[] = [
|
||||
'host_id' => $id,
|
||||
'hostname' => $v,
|
||||
'is_local' => $isLocal,
|
||||
'key_var' => $keyVar,
|
||||
'key_name' => 'Varaverk_' . ($isLocal ? $myId : $myId), // Varaverk_HOST1 on that registry
|
||||
'key_present' => !empty($key),
|
||||
'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : null,
|
||||
'api_ok' => $isLocal
|
||||
? (!$localStatus['key_missing'] && $localStatus['available'])
|
||||
: !empty($key),
|
||||
];
|
||||
}
|
||||
usort($hosts, fn($a, $b) => strcmp($a['host_id'], $b['host_id']));
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'my_id' => $myId,
|
||||
'hosts' => $hosts,
|
||||
'fallbacks' => $localStatus['fallbacks'],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Setup/renew API keys (local + all partners via SSH) ───────────────────────
|
||||
if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$script = SCRIPTS_DIR . '/System_Essentials/unraid_api_key_renew.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
|
||||
}
|
||||
$allHosts = ($_POST['all_hosts'] ?? '0') === '1';
|
||||
$flags = $allHosts ? ' --all-hosts' : '';
|
||||
set_time_limit(60);
|
||||
$output = []; $exit = 0;
|
||||
exec('bash ' . escapeshellarg($script) . $flags . ' 2>&1', $output, $exit);
|
||||
echo json_encode(['ok' => $exit === 0, 'exit' => $exit, 'output' => implode("\n", $output)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||
|
||||
// ── Boot device detection ─────────────────────────────────────────────────────
|
||||
function vv_storage_detect_transport(): string {
|
||||
$part = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
|
||||
if (!$part) return 'unknown';
|
||||
$disk = trim(shell_exec("lsblk -no pkname " . escapeshellarg($part) . " 2>/dev/null") ?: '');
|
||||
if (!$disk) return 'unknown';
|
||||
return strtolower(trim(shell_exec("lsblk -dno TRAN /dev/" . escapeshellarg($disk) . " 2>/dev/null") ?: 'unknown'));
|
||||
}
|
||||
|
||||
// ── Current mode status ───────────────────────────────────────────────────────
|
||||
if ($action === 'status') {
|
||||
$transport = vv_storage_detect_transport();
|
||||
$detected = ($transport === 'usb') ? 'flash' : 'internal';
|
||||
$currentDir = SCRIPTS_DIR;
|
||||
$internalDir = '/boot/config/plugins/varaverk';
|
||||
$flashDir = '/mnt/user/appdata/Varaverk';
|
||||
$currentMode = ($currentDir === $internalDir) ? 'internal'
|
||||
: ($currentDir === $flashDir ? 'flash' : 'custom');
|
||||
|
||||
$myHost = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
|
||||
$confVal = $vars[$confKey] ?? null;
|
||||
|
||||
// Boot device name for display
|
||||
$bootPart = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
|
||||
$bootDisk = $bootPart ? trim(shell_exec("lsblk -no pkname " . escapeshellarg($bootPart) . " 2>/dev/null") ?: '') : '';
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'current_mode' => $currentMode,
|
||||
'current_dir' => $currentDir,
|
||||
'internal_dir' => $internalDir,
|
||||
'flash_dir' => $flashDir,
|
||||
'transport' => $transport,
|
||||
'detected' => $detected,
|
||||
'boot_disk' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
|
||||
'conf_key' => $confKey,
|
||||
'conf_val' => $confVal,
|
||||
'array_started'=> is_dir('/mnt/user') && count(scandir('/mnt/user')) > 2,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Run migration ─────────────────────────────────────────────────────────────
|
||||
if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$to = trim($_POST['to'] ?? '');
|
||||
if (!in_array($to, ['internal', 'flash'], true)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid target: must be internal or flash']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$script = dirname(__DIR__) . '/tools/storage_migrate.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
set_time_limit(300);
|
||||
$output = [];
|
||||
$exit = 0;
|
||||
exec('bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => $exit === 0,
|
||||
'exit' => $exit,
|
||||
'output' => implode("\n", $output),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Auto-detect and write to conf ─────────────────────────────────────────────
|
||||
if ($action === 'detect' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$transport = vv_storage_detect_transport();
|
||||
$detected = ($transport === 'usb') ? 'false' : 'true';
|
||||
$myHost = vv_detect_host();
|
||||
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
|
||||
$confFile = $myHost . '.conf';
|
||||
|
||||
$results = vv_conf_write_changes([[
|
||||
'file' => $confFile,
|
||||
'key' => $confKey,
|
||||
'value' => $detected,
|
||||
'type' => 'scalar',
|
||||
]]);
|
||||
|
||||
$ok = !in_array(false, $results, true);
|
||||
echo json_encode(['ok' => $ok, 'detected' => $detected, 'transport' => $transport]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Unraid API key status ─────────────────────────────────────────────────────
|
||||
// Keys live in host*.conf (private, not master.conf):
|
||||
// host1.conf: HOST1_UNRAID_API_KEY (own) + HOST2_UNRAID_API_KEY (HOST1's access to HOST2)
|
||||
// host2.conf: HOST2_UNRAID_API_KEY (own) + HOST1_UNRAID_API_KEY (HOST2's access to HOST1)
|
||||
if ($action === 'api_status') {
|
||||
require_once dirname(__DIR__) . '/include/unraid_api.php';
|
||||
$localStatus = vv_api_get_status();
|
||||
$vars = vv_conf_vars();
|
||||
$myHost = vv_detect_host();
|
||||
$myId = strtoupper($myHost);
|
||||
|
||||
$hosts = [];
|
||||
foreach ($vars as $k => $v) {
|
||||
if (!preg_match('/^HOST(\d+)$/', $k, $m) || !$v) continue;
|
||||
$id = 'HOST' . $m[1];
|
||||
$keyVar = $id . '_UNRAID_API_KEY';
|
||||
$key = $vars[$keyVar] ?? '';
|
||||
$isLocal = ($id === $myId);
|
||||
// For local: key is Varaverk_HOST1 registered on own machine
|
||||
// For remote: key is Varaverk_HOST1 registered on HOST2's machine (stored in host1.conf)
|
||||
$hosts[] = [
|
||||
'host_id' => $id,
|
||||
'hostname' => $v,
|
||||
'is_local' => $isLocal,
|
||||
'key_var' => $keyVar,
|
||||
'key_name' => 'Varaverk_' . ($isLocal ? $myId : $myId), // Varaverk_HOST1 on that registry
|
||||
'key_present' => !empty($key),
|
||||
'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : null,
|
||||
'api_ok' => $isLocal
|
||||
? (!$localStatus['key_missing'] && $localStatus['available'])
|
||||
: !empty($key),
|
||||
];
|
||||
}
|
||||
usort($hosts, fn($a, $b) => strcmp($a['host_id'], $b['host_id']));
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'my_id' => $myId,
|
||||
'hosts' => $hosts,
|
||||
'fallbacks' => $localStatus['fallbacks'],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Setup/renew API keys (local + all partners via SSH) ───────────────────────
|
||||
if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$script = SCRIPTS_DIR . '/System_Essentials/unraid_api_key_renew.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
|
||||
}
|
||||
$allHosts = ($_POST['all_hosts'] ?? '0') === '1';
|
||||
$flags = $allHosts ? ' --all-hosts' : '';
|
||||
set_time_limit(60);
|
||||
$output = []; $exit = 0;
|
||||
exec('bash ' . escapeshellarg($script) . $flags . ' 2>&1', $output, $exit);
|
||||
echo json_encode(['ok' => $exit === 0, 'exit' => $exit, 'output' => implode("\n", $output)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||
|
||||
// ── Boot device detection ─────────────────────────────────────────────────────
|
||||
function vv_storage_detect_transport(): string {
|
||||
$part = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
|
||||
if (!$part) return 'unknown';
|
||||
$disk = trim(shell_exec("lsblk -no pkname " . escapeshellarg($part) . " 2>/dev/null") ?: '');
|
||||
if (!$disk) return 'unknown';
|
||||
return strtolower(trim(shell_exec("lsblk -dno TRAN /dev/" . escapeshellarg($disk) . " 2>/dev/null") ?: 'unknown'));
|
||||
}
|
||||
|
||||
// ── Current mode status ───────────────────────────────────────────────────────
|
||||
if ($action === 'status') {
|
||||
$transport = vv_storage_detect_transport();
|
||||
$detected = ($transport === 'usb') ? 'flash' : 'internal';
|
||||
$currentDir = SCRIPTS_DIR;
|
||||
$internalDir = '/boot/config/plugins/varaverk';
|
||||
$flashDir = '/mnt/user/appdata/Varaverk';
|
||||
$currentMode = ($currentDir === $internalDir) ? 'internal'
|
||||
: ($currentDir === $flashDir ? 'flash' : 'custom');
|
||||
|
||||
$myHost = vv_detect_host();
|
||||
$vars = vv_conf_vars();
|
||||
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
|
||||
$confVal = $vars[$confKey] ?? null;
|
||||
|
||||
// Boot device name for display
|
||||
$bootPart = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
|
||||
$bootDisk = $bootPart ? trim(shell_exec("lsblk -no pkname " . escapeshellarg($bootPart) . " 2>/dev/null") ?: '') : '';
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'current_mode' => $currentMode,
|
||||
'current_dir' => $currentDir,
|
||||
'internal_dir' => $internalDir,
|
||||
'flash_dir' => $flashDir,
|
||||
'transport' => $transport,
|
||||
'detected' => $detected,
|
||||
'boot_disk' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
|
||||
'conf_key' => $confKey,
|
||||
'conf_val' => $confVal,
|
||||
'array_started'=> is_dir('/mnt/user') && count(scandir('/mnt/user')) > 2,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Run migration ─────────────────────────────────────────────────────────────
|
||||
if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$to = trim($_POST['to'] ?? '');
|
||||
if (!in_array($to, ['internal', 'flash'], true)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid target: must be internal or flash']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$script = dirname(__DIR__) . '/Tools/storage_migrate.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
set_time_limit(300);
|
||||
$output = [];
|
||||
$exit = 0;
|
||||
exec('bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => $exit === 0,
|
||||
'exit' => $exit,
|
||||
'output' => implode("\n", $output),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Auto-detect and write to conf ─────────────────────────────────────────────
|
||||
if ($action === 'detect' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$transport = vv_storage_detect_transport();
|
||||
$detected = ($transport === 'usb') ? 'false' : 'true';
|
||||
$myHost = vv_detect_host();
|
||||
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
|
||||
$confFile = $myHost . '.conf';
|
||||
|
||||
$results = vv_conf_write_changes([[
|
||||
'file' => $confFile,
|
||||
'key' => $confKey,
|
||||
'value' => $detected,
|
||||
'type' => 'scalar',
|
||||
]]);
|
||||
|
||||
$ok = !in_array(false, $results, true);
|
||||
echo json_encode(['ok' => $ok, 'detected' => $detected, 'transport' => $transport]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Unraid API key status ─────────────────────────────────────────────────────
|
||||
// Keys live in host*.conf (private, not master.conf):
|
||||
// host1.conf: HOST1_UNRAID_API_KEY (own) + HOST2_UNRAID_API_KEY (HOST1's access to HOST2)
|
||||
// host2.conf: HOST2_UNRAID_API_KEY (own) + HOST1_UNRAID_API_KEY (HOST2's access to HOST1)
|
||||
if ($action === 'api_status') {
|
||||
require_once dirname(__DIR__) . '/include/unraid_api.php';
|
||||
$localStatus = vv_api_get_status();
|
||||
$vars = vv_conf_vars();
|
||||
$myHost = vv_detect_host();
|
||||
$myId = strtoupper($myHost);
|
||||
|
||||
$hosts = [];
|
||||
foreach ($vars as $k => $v) {
|
||||
if (!preg_match('/^HOST(\d+)$/', $k, $m) || !$v) continue;
|
||||
$id = 'HOST' . $m[1];
|
||||
$keyVar = $id . '_UNRAID_API_KEY';
|
||||
$key = $vars[$keyVar] ?? '';
|
||||
$isLocal = ($id === $myId);
|
||||
// For local: key is Varaverk_HOST1 registered on own machine
|
||||
// For remote: key is Varaverk_HOST1 registered on HOST2's machine (stored in host1.conf)
|
||||
$hosts[] = [
|
||||
'host_id' => $id,
|
||||
'hostname' => $v,
|
||||
'is_local' => $isLocal,
|
||||
'key_var' => $keyVar,
|
||||
'key_name' => 'Varaverk_' . ($isLocal ? $myId : $myId), // Varaverk_HOST1 on that registry
|
||||
'key_present' => !empty($key),
|
||||
'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : null,
|
||||
'api_ok' => $isLocal
|
||||
? (!$localStatus['key_missing'] && $localStatus['available'])
|
||||
: !empty($key),
|
||||
];
|
||||
}
|
||||
usort($hosts, fn($a, $b) => strcmp($a['host_id'], $b['host_id']));
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'my_id' => $myId,
|
||||
'hosts' => $hosts,
|
||||
'fallbacks' => $localStatus['fallbacks'],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Setup/renew API keys (local + all partners via SSH) ───────────────────────
|
||||
if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$script = SCRIPTS_DIR . '/Plugin/unraid/System_Essentials/unraid_api_key_renew.sh';
|
||||
if (!file_exists($script)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
|
||||
}
|
||||
$allHosts = ($_POST['all_hosts'] ?? '0') === '1';
|
||||
$flags = $allHosts ? ' --all-hosts' : '';
|
||||
set_time_limit(60);
|
||||
$output = []; $exit = 0;
|
||||
exec('bash ' . escapeshellarg($script) . $flags . ' 2>&1', $output, $exit);
|
||||
echo json_encode(['ok' => $exit === 0, 'exit' => $exit, 'output' => implode("\n", $output)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => false, 'error' => 'Unknown action']);
|
||||
@@ -0,0 +1,800 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/unraid_api.php';
|
||||
|
||||
// Common helpers shared across all Varaverk pages.
|
||||
|
||||
function vv_system_info(): array {
|
||||
// ── Shared local reads (always needed regardless of API) ──────────────────
|
||||
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
|
||||
$var = [];
|
||||
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
|
||||
}
|
||||
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
|
||||
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api) {
|
||||
$os = $api['info']['os'] ?? [];
|
||||
$cpu = $api['info']['cpu'] ?? [];
|
||||
|
||||
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
|
||||
$uptimeRaw = $os['uptime'] ?? '';
|
||||
if (is_numeric($uptimeRaw)) {
|
||||
$uptimeSec = (int)$uptimeRaw;
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
} else {
|
||||
$uptimeSec = 0;
|
||||
$uptime = $uptimeRaw ?: '—';
|
||||
}
|
||||
|
||||
$load = sys_getloadavg();
|
||||
return [
|
||||
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
|
||||
'comment' => $ident['COMMENT'] ?? '',
|
||||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||||
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
|
||||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||||
'cpu_cores' => (int)($cpu['cores'] ?? 0),
|
||||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||||
'reg_to' => $var['regTo'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
|
||||
'version' => trim($os['release'] ?? '') ?: $version,
|
||||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('system_info');
|
||||
$cpuModel = '';
|
||||
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
|
||||
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
|
||||
}
|
||||
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
|
||||
$load = sys_getloadavg();
|
||||
return [
|
||||
'name' => $ident['NAME'] ?? gethostname(),
|
||||
'comment' => $ident['COMMENT'] ?? '',
|
||||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||||
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
|
||||
'cpu_threads' => 0,
|
||||
'cpu_cores' => 0,
|
||||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||||
'reg_to' => $var['regTo'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'array_state' => $var['mdState'] ?? 'UNKNOWN',
|
||||
'version' => $version,
|
||||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_docker_containers(): array {
|
||||
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
|
||||
$containers = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$c = json_decode($line, true);
|
||||
if ($c) $containers[] = $c;
|
||||
}
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function vv_docker_stopped(): array {
|
||||
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
|
||||
$containers = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$c = json_decode($line, true);
|
||||
if ($c) $containers[] = $c;
|
||||
}
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function vv_gpu_stats(): array {
|
||||
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
|
||||
if (!$out) return ['available' => false];
|
||||
|
||||
$parts = array_map('trim', explode(',', $out));
|
||||
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
|
||||
return [
|
||||
'available' => true,
|
||||
'name' => $parts[0] ?? '',
|
||||
'memory_used' => (int)($parts[1] ?? 0),
|
||||
'memory_total' => (int)($parts[2] ?? 0),
|
||||
'utilization' => (int)($parts[3] ?? 0),
|
||||
'temperature' => (int)($parts[4] ?? 0),
|
||||
'power_w' => $power,
|
||||
'enc_pct' => (int)($parts[6] ?? 0),
|
||||
'dec_pct' => (int)($parts[7] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
function vv_gpu_processes(): array {
|
||||
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
|
||||
$procs = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$parts = array_map('trim', explode(',', $line));
|
||||
$procs[] = [
|
||||
'pid' => $parts[0] ?? '',
|
||||
'memory_mb' => $parts[1] ?? '',
|
||||
'name' => $parts[2] ?? '',
|
||||
];
|
||||
}
|
||||
return $procs;
|
||||
}
|
||||
|
||||
function vv_system_resources(): array {
|
||||
$mem = [];
|
||||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||||
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
|
||||
$mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
return [
|
||||
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
|
||||
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
|
||||
'cache' => vv_df('/mnt/cache'),
|
||||
];
|
||||
}
|
||||
|
||||
function vv_cpu_per_core(): array {
|
||||
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
|
||||
$raw = [];
|
||||
foreach (file('/proc/stat') ?: [] as $line) {
|
||||
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
|
||||
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
|
||||
}
|
||||
|
||||
$stateFile = '/tmp/vv_cpu_stat.json';
|
||||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||||
// Atomic write — concurrent fast/slow polls read a consistent snapshot
|
||||
$tmp = $stateFile . '.tmp';
|
||||
file_put_contents($tmp, json_encode($raw));
|
||||
rename($tmp, $stateFile);
|
||||
|
||||
$usage = function(array $c, ?array $p): int {
|
||||
if (!$p) return 0;
|
||||
$dt = array_sum($c) - array_sum($p);
|
||||
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
|
||||
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
|
||||
};
|
||||
|
||||
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
|
||||
$cores = [];
|
||||
foreach ($raw as $cpu => $c) {
|
||||
if ($cpu === 'cpu') continue;
|
||||
$num = (int)substr($cpu, 3);
|
||||
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
|
||||
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
|
||||
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
|
||||
$cores[] = [
|
||||
'core' => $num,
|
||||
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
|
||||
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
|
||||
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
|
||||
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
|
||||
];
|
||||
}
|
||||
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
|
||||
return ['overall' => $overall, 'cores' => $cores];
|
||||
}
|
||||
|
||||
function vv_memory_breakdown(): array {
|
||||
$mem = [];
|
||||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
$totalKb = $mem['MemTotal'] ?? 0;
|
||||
|
||||
// ZFS ARC
|
||||
$arcKb = 0;
|
||||
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
|
||||
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
|
||||
}
|
||||
|
||||
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
|
||||
$dockerKb = 0;
|
||||
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
|
||||
foreach (explode("\n", trim($dsOut)) as $line) {
|
||||
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
|
||||
$val = (float)$m[1];
|
||||
$dockerKb += match($m[2]) {
|
||||
'GiB' => (int)($val * 1048576),
|
||||
'MiB' => (int)($val * 1024),
|
||||
'KiB' => (int)$val,
|
||||
default => (int)($val / 1024),
|
||||
};
|
||||
}
|
||||
|
||||
// VM (QEMU/KVM RSS)
|
||||
$vmKb = 0;
|
||||
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
|
||||
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
|
||||
}
|
||||
|
||||
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
|
||||
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
|
||||
|
||||
// Top processes by RSS — group same-named procs, take top 5
|
||||
$grouped = [];
|
||||
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
|
||||
foreach (explode("\n", trim($psOut)) as $line) {
|
||||
$parts = preg_split('/\s+/', trim($line), 2);
|
||||
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
|
||||
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
|
||||
}
|
||||
arsort($grouped);
|
||||
$topProcs = [];
|
||||
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
|
||||
$topProcs[] = ['name' => $name, 'kb' => $kb];
|
||||
|
||||
// Swap — from API metrics when available, else /proc/meminfo
|
||||
$swapTotalKb = 0; $swapUsedKb = 0;
|
||||
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
|
||||
if (!empty($apiMem['swapTotal'])) {
|
||||
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
|
||||
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
|
||||
} else {
|
||||
$swapTotalKb = $mem['SwapTotal'] ?? 0;
|
||||
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'total_kb' => $totalKb,
|
||||
'system_kb' => $systemKb,
|
||||
'vm_kb' => $vmKb,
|
||||
'zfs_kb' => $arcKb,
|
||||
'docker_kb' => $dockerKb,
|
||||
'free_kb' => $freeKb,
|
||||
'swap_total_kb' => $swapTotalKb,
|
||||
'swap_used_kb' => $swapUsedKb,
|
||||
'top_procs' => $topProcs,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_df(string $path): array {
|
||||
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
|
||||
if (!$out) return ['available' => false, 'path' => $path];
|
||||
$parts = preg_split('/\s+/', trim($out));
|
||||
return [
|
||||
'available' => true,
|
||||
'path' => $path,
|
||||
'size_mb' => (int)$parts[0],
|
||||
'used_mb' => (int)$parts[1],
|
||||
'free_mb' => (int)$parts[2],
|
||||
];
|
||||
}
|
||||
|
||||
function vv_network_stats(): array {
|
||||
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
|
||||
if (!$iface) {
|
||||
$best = ''; $bestBytes = 0;
|
||||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||||
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
|
||||
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
|
||||
}
|
||||
$iface = $best;
|
||||
}
|
||||
if (!$iface) return ['available' => false];
|
||||
|
||||
$rxBytes = $txBytes = 0;
|
||||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||||
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
|
||||
$parts = preg_split('/\s+/', trim($m[1]));
|
||||
$rxBytes = (int)($parts[0] ?? 0);
|
||||
$txBytes = (int)($parts[8] ?? 0);
|
||||
break;
|
||||
}
|
||||
|
||||
$stateFile = '/tmp/vv_net_stat.json';
|
||||
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
|
||||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||||
$tmp = $stateFile . '.tmp';
|
||||
file_put_contents($tmp, json_encode($now));
|
||||
rename($tmp, $stateFile);
|
||||
|
||||
$rxRate = $txRate = 0;
|
||||
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
|
||||
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
|
||||
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
|
||||
}
|
||||
|
||||
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
|
||||
|
||||
// Local IP — use primary iface
|
||||
$localIp = trim(shell_exec(
|
||||
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
|
||||
) ?: '');
|
||||
|
||||
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
|
||||
$extIp = '';
|
||||
$extData = vv_cache_read('ext_ip', 300);
|
||||
if ($extData) {
|
||||
$extIp = $extData['ip'] ?? '';
|
||||
} else {
|
||||
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
|
||||
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
|
||||
$extIp = $fetched;
|
||||
vv_cache_write('ext_ip', ['ip' => $extIp]);
|
||||
}
|
||||
}
|
||||
|
||||
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
|
||||
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'iface' => $iface,
|
||||
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
|
||||
'rx_bps' => $rxRate,
|
||||
'tx_bps' => $txRate,
|
||||
'local_ip' => $localIp,
|
||||
'ext_ip' => $extIp,
|
||||
'ts_ip' => $tsIp,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
|
||||
$name = $d['name'] ?? $key;
|
||||
$isParity = $role === 'parity';
|
||||
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
|
||||
// Parity has no filesystem — use raw size only
|
||||
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
|
||||
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
|
||||
if ($size_kb <= 0) return null;
|
||||
$tempRaw = trim($d['temp'] ?? '');
|
||||
return [
|
||||
'name' => $name,
|
||||
'device' => $d['device'] ?? $key,
|
||||
'role' => $role,
|
||||
'size_gb' => round($size_kb / 1048576, 1),
|
||||
'used_gb' => round($used_kb / 1048576, 1),
|
||||
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
|
||||
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
|
||||
'transport' => $d['transport'] ?? 'ata',
|
||||
'mounted' => $mounted,
|
||||
'status' => $d['status'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
function vv_ups_stats(): array {
|
||||
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
|
||||
if (!$raw) return ['available' => false];
|
||||
|
||||
$fields = [];
|
||||
foreach (explode("\n", $raw) as $line) {
|
||||
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
|
||||
$fields[trim($m[1])] = trim($m[2]);
|
||||
}
|
||||
}
|
||||
if (empty($fields)) return ['available' => false];
|
||||
|
||||
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
|
||||
|
||||
$loadPct = $parse_num('LOADPCT');
|
||||
$nomPower = $parse_num('NOMPOWER');
|
||||
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'model' => $fields['MODEL'] ?? '',
|
||||
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
|
||||
'line_v' => $parse_num('LINEV'),
|
||||
'output_v' => $parse_num('OUTPUTV'),
|
||||
'load_pct' => $loadPct,
|
||||
'nom_power' => $nomPower,
|
||||
'watts' => $watts,
|
||||
'bcharge' => $parse_num('BCHARGE'),
|
||||
'timeleft' => $parse_num('TIMELEFT'),
|
||||
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
|
||||
'on_batt_s' => $parse_num('CUMONBATT'),
|
||||
'selftest' => $fields['SELFTEST'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
function vv_parity_status(): array {
|
||||
$var = [];
|
||||
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
|
||||
}
|
||||
|
||||
$isValid = ($var['mdNumInvalid'] ?? '0') === '0';
|
||||
$exitCode = (int)($var['sbSyncExit'] ?? 0);
|
||||
$errors = (int)($var['sbSyncErrs'] ?? 0);
|
||||
$inProgress = ($var['mdResync'] ?? '0') !== '0';
|
||||
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
|
||||
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
|
||||
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
|
||||
|
||||
// Last check from log
|
||||
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
|
||||
$logFile = '/boot/config/parity-checks.log';
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
if ($lines) {
|
||||
$p = explode('|', trim(end($lines)));
|
||||
$lastDate = trim($p[0] ?? '');
|
||||
$lastDuration = (int)($p[1] ?? 0);
|
||||
$lastSpeed = (int)($p[2] ?? 0);
|
||||
$lastExit = (int)($p[3] ?? 0);
|
||||
$lastErrors = (int)($p[4] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse last date string to timestamp
|
||||
$lastTs = $lastDate ? strtotime($lastDate) : null;
|
||||
|
||||
// Next scheduled check from cron
|
||||
$nextTs = null;
|
||||
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
|
||||
foreach (@file($cronFile) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
if (!str_contains($line, 'mdcmd')) continue;
|
||||
$p = preg_split('/\s+/', $line);
|
||||
// cron: min hour dom month dow command...
|
||||
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
|
||||
$next = new DateTime('now');
|
||||
$next->setTime((int)$p[1], (int)$p[0], 0);
|
||||
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
|
||||
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
|
||||
$nextTs = $next->getTimestamp();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
|
||||
return [
|
||||
'valid' => $isValid,
|
||||
'in_progress' => $inProgress,
|
||||
'resync_pct' => $resyncPct,
|
||||
'exit_code' => $exitCode,
|
||||
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
|
||||
'errors' => $lastErrors,
|
||||
'last_date' => $lastDate,
|
||||
'last_ts' => $lastTs,
|
||||
'last_duration' => $lastDuration,
|
||||
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
|
||||
'next_ts' => $nextTs,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_storage_pools(): array {
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api && isset($api['array']['caches'])) {
|
||||
$out = [];
|
||||
foreach ($api['array']['caches'] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'data');
|
||||
if ($entry) $out[] = $entry;
|
||||
}
|
||||
if (!empty($out)) {
|
||||
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('storage_pools');
|
||||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||||
$out = [];
|
||||
foreach ($ini as $key => $d) {
|
||||
if (($d['type'] ?? '') !== 'Cache') continue;
|
||||
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
|
||||
$entry = vv_disk_entry($d, $key);
|
||||
if ($entry) $out[] = $entry;
|
||||
}
|
||||
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
function vv_array_disks(): array {
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
|
||||
$parity = [];
|
||||
$data = [];
|
||||
foreach ($api['array']['parities'] ?? [] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'parity');
|
||||
if ($entry) $parity[] = $entry;
|
||||
}
|
||||
foreach ($api['array']['disks'] ?? [] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'data');
|
||||
if ($entry) $data[] = $entry;
|
||||
}
|
||||
if (!empty($parity) || !empty($data)) {
|
||||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
return array_merge($parity, $data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('array_disks');
|
||||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||||
$parity = [];
|
||||
$data = [];
|
||||
foreach ($ini as $key => $d) {
|
||||
$type = $d['type'] ?? '';
|
||||
if ($type === 'Parity') {
|
||||
$entry = vv_disk_entry($d, $key, 'parity');
|
||||
if ($entry) $parity[] = $entry;
|
||||
} elseif ($type === 'Data') {
|
||||
$entry = vv_disk_entry($d, $key, 'data');
|
||||
if ($entry) $data[] = $entry;
|
||||
}
|
||||
}
|
||||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
return array_merge($parity, $data);
|
||||
}
|
||||
|
||||
function vv_disk_io_rates(): array {
|
||||
$snapFile = '/tmp/vv_diskio_snap.json';
|
||||
$now = microtime(true);
|
||||
|
||||
// Read current whole-disk stats from /proc/diskstats
|
||||
$current = [];
|
||||
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||
$p = preg_split('/\s+/', trim($line));
|
||||
if (count($p) < 14) continue;
|
||||
$dev = $p[2];
|
||||
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
|
||||
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
|
||||
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
|
||||
}
|
||||
|
||||
// Load previous snapshot
|
||||
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
|
||||
$prevTime = (float)($snap['t'] ?? $now);
|
||||
$prev = $snap['d'] ?? [];
|
||||
|
||||
// Save current snapshot
|
||||
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$dt = max(0.5, $now - $prevTime);
|
||||
$out = [];
|
||||
foreach ($current as $dev => [$rs, $ws]) {
|
||||
$entry = [
|
||||
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
|
||||
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
|
||||
];
|
||||
if (isset($prev[$dev])) {
|
||||
[$prs, $pws] = $prev[$dev];
|
||||
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
|
||||
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
|
||||
if ($r > 0.01) $entry['r'] = round($r, 1);
|
||||
if ($w > 0.01) $entry['w'] = round($w, 1);
|
||||
}
|
||||
$out[$dev] = $entry;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function vv_disk_thresholds(): array {
|
||||
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
|
||||
$get = function(string $key) use ($cfg): ?int {
|
||||
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
|
||||
? (int)$m[1] : null;
|
||||
};
|
||||
return [
|
||||
'util_warn' => $get('warning') ?? 70,
|
||||
'util_crit' => $get('critical') ?? 90,
|
||||
'hdd_warn' => $get('hot') ?? 45,
|
||||
'hdd_crit' => $get('max') ?? 55,
|
||||
'ssd_warn' => $get('hotssd') ?? 60,
|
||||
'ssd_crit' => $get('maxssd') ?? 70,
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch a lightweight snapshot from each remote host that has an API key configured.
|
||||
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
|
||||
function vv_remote_hosts_stats(): array {
|
||||
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
|
||||
$vars = vv_conf_vars();
|
||||
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
|
||||
$raw = file_get_contents($f) ?: '';
|
||||
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
||||
foreach ($m[1] as $i => $key) {
|
||||
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
|
||||
}
|
||||
}
|
||||
$myHost = vv_detect_host();
|
||||
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
|
||||
sort($hostIds);
|
||||
|
||||
$results = [];
|
||||
foreach ($hostIds as $id) {
|
||||
if (strtolower($id) === strtolower($myHost)) continue;
|
||||
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
|
||||
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
|
||||
if (file_exists($bgCache)) {
|
||||
$cached = json_decode(file_get_contents($bgCache), true);
|
||||
if ($cached) {
|
||||
$cached['cache_age'] = time() - (int)filemtime($bgCache);
|
||||
$results[$id] = $cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No background cache yet — fall back to live call (uses 30s inline cache).
|
||||
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
|
||||
if (!$key) {
|
||||
$results[$id] = ['available' => false, 'no_api_key' => true,
|
||||
'host_id' => $id, 'hostname' => $vars[$id]];
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheFile = "/tmp/vv_remote_{$id}.json";
|
||||
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
|
||||
$cached = json_decode(file_get_contents($cacheFile), true);
|
||||
if ($cached) { $results[$id] = $cached; continue; }
|
||||
}
|
||||
|
||||
$gql = '{
|
||||
info { os { hostname uptime release } cpu { brand threads cores } }
|
||||
metrics { cpu { percentTotal } memory { percentTotal total used available } }
|
||||
array {
|
||||
state
|
||||
disks { fsSize fsUsed temp }
|
||||
caches { fsSize fsUsed temp }
|
||||
parities { temp }
|
||||
}
|
||||
vms { domains { name } }
|
||||
}';
|
||||
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
|
||||
|
||||
if (!$data) {
|
||||
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
|
||||
file_put_contents($cacheFile, json_encode($entry));
|
||||
$results[$id] = $entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
$os = $data['info']['os'] ?? [];
|
||||
$cpu = $data['info']['cpu'] ?? [];
|
||||
$mMem = $data['metrics']['memory'] ?? [];
|
||||
|
||||
$memPct = round((float)($mMem['percentTotal'] ?? 0));
|
||||
if ($memPct === 0) {
|
||||
$totalBytes = (float)($mMem['total'] ?? 0);
|
||||
$availBytes = (float)($mMem['available'] ?? 0);
|
||||
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
|
||||
}
|
||||
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
|
||||
|
||||
$uptimeRaw = $os['uptime'] ?? '';
|
||||
if (is_numeric($uptimeRaw)) {
|
||||
$uptimeSec = (int)$uptimeRaw;
|
||||
$days = intdiv($uptimeSec, 86400);
|
||||
$hours = intdiv($uptimeSec % 86400, 3600);
|
||||
$mins = intdiv($uptimeSec % 3600, 60);
|
||||
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
|
||||
} else {
|
||||
$uptimeSec = 0;
|
||||
$uptime = $uptimeRaw ?: '—';
|
||||
}
|
||||
|
||||
$nodeMetrics = vv_api_node_metrics($data);
|
||||
$entry = array_merge([
|
||||
'available' => true,
|
||||
'host_id' => $id,
|
||||
'hostname' => $os['hostname'] ?? $vars[$id],
|
||||
'version' => $os['release'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
|
||||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||||
'mem_total_gb' => $memTotalGb,
|
||||
'mem_used_pct' => $memPct,
|
||||
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
|
||||
], $nodeMetrics);
|
||||
file_put_contents($cacheFile, json_encode($entry));
|
||||
$results[$id] = $entry;
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
function vv_log_tail(string $path, int $lines): string {
|
||||
$fp = @fopen($path, 'r');
|
||||
if (!$fp) return '';
|
||||
fseek($fp, 0, SEEK_END);
|
||||
$size = ftell($fp);
|
||||
if ($size <= 0) { fclose($fp); return ''; }
|
||||
$chunk = min($size, 4096);
|
||||
fseek($fp, -$chunk, SEEK_END);
|
||||
$data = fread($fp, $chunk);
|
||||
fclose($fp);
|
||||
$all = explode("\n", $data ?: '');
|
||||
return implode("\n", array_slice($all, -$lines));
|
||||
}
|
||||
|
||||
function vv_parse_bash_array(string $raw, string $varName): array {
|
||||
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
|
||||
$items = [];
|
||||
foreach (explode("\n", $m[1]) as $line) {
|
||||
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
|
||||
if ($line !== '') $items[] = $line;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
function vv_transcode_sessions(): array {
|
||||
$stateFile = '/tmp/transcode_state.db';
|
||||
if (!file_exists($stateFile)) return ['available' => false];
|
||||
|
||||
$raw = [];
|
||||
foreach (file($stateFile) ?: [] as $line) {
|
||||
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
|
||||
$raw[trim($k)] = trim($v);
|
||||
}
|
||||
|
||||
$target = $raw['current_target'] ?? '';
|
||||
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
|
||||
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
|
||||
$isRamdisk = str_contains($target, 'ramdisk');
|
||||
|
||||
// Count active session dirs in both known locations
|
||||
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
|
||||
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
|
||||
|
||||
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
|
||||
$ssdPath = '';
|
||||
$ssdSessions = 0;
|
||||
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
|
||||
$parts = explode('/', rtrim($p, '/'));
|
||||
array_pop($parts);
|
||||
$mount = implode('/', $parts) ?: '/';
|
||||
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
|
||||
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
|
||||
$ssdPath = $p;
|
||||
break;
|
||||
}
|
||||
$ssd = ['available' => false];
|
||||
if ($ssdPath) {
|
||||
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
|
||||
$parts = explode('/', rtrim($ssdPath, '/'));
|
||||
array_pop($parts);
|
||||
$ssdMount = implode('/', $parts) ?: '/';
|
||||
$ssd = vv_df($ssdMount);
|
||||
}
|
||||
|
||||
// Ramdisk disk usage
|
||||
$rd = vv_df('/mnt/ramdisk_transcodes');
|
||||
|
||||
// Last cleanup values from transcode management log
|
||||
$lastRdFreed = null;
|
||||
$lastSsdFreed = null;
|
||||
$logFile = '/var/log/varaverk/Orchestrators/transcode_management.log';
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach (array_reverse($lines) as $line) {
|
||||
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
|
||||
$lastRdFreed = $m[1];
|
||||
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
|
||||
$lastSsdFreed = $m[1];
|
||||
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'current_target' => $target,
|
||||
'is_ramdisk' => $isRamdisk,
|
||||
'flip_count_hour' => $flipCount,
|
||||
'last_flip_time' => $lastFlip,
|
||||
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
|
||||
'ram_sessions' => $ramSessions,
|
||||
'ssd_sessions' => $ssdSessions,
|
||||
'ramdisk' => $rd,
|
||||
'ssd' => $ssd,
|
||||
'last_rd_freed' => $lastRdFreed,
|
||||
'last_ssd_freed' => $lastSsdFreed,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/unraid_api.php';
|
||||
|
||||
// Common helpers shared across all Varaverk pages.
|
||||
|
||||
function vv_system_info(): array {
|
||||
// ── Shared local reads (always needed regardless of API) ──────────────────
|
||||
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
|
||||
$var = [];
|
||||
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
|
||||
}
|
||||
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
|
||||
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api) {
|
||||
$os = $api['info']['os'] ?? [];
|
||||
$cpu = $api['info']['cpu'] ?? [];
|
||||
|
||||
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
|
||||
$uptimeRaw = $os['uptime'] ?? '';
|
||||
if (is_numeric($uptimeRaw)) {
|
||||
$uptimeSec = (int)$uptimeRaw;
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
} else {
|
||||
$uptimeSec = 0;
|
||||
$uptime = $uptimeRaw ?: '—';
|
||||
}
|
||||
|
||||
$load = sys_getloadavg();
|
||||
return [
|
||||
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
|
||||
'comment' => $ident['COMMENT'] ?? '',
|
||||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||||
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
|
||||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||||
'cpu_cores' => (int)($cpu['cores'] ?? 0),
|
||||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||||
'reg_to' => $var['regTo'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
|
||||
'version' => trim($os['release'] ?? '') ?: $version,
|
||||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('system_info');
|
||||
$cpuModel = '';
|
||||
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
|
||||
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
|
||||
}
|
||||
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
|
||||
$load = sys_getloadavg();
|
||||
return [
|
||||
'name' => $ident['NAME'] ?? gethostname(),
|
||||
'comment' => $ident['COMMENT'] ?? '',
|
||||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||||
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
|
||||
'cpu_threads' => 0,
|
||||
'cpu_cores' => 0,
|
||||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||||
'reg_to' => $var['regTo'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'array_state' => $var['mdState'] ?? 'UNKNOWN',
|
||||
'version' => $version,
|
||||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_docker_containers(): array {
|
||||
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
|
||||
$containers = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$c = json_decode($line, true);
|
||||
if ($c) $containers[] = $c;
|
||||
}
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function vv_docker_stopped(): array {
|
||||
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
|
||||
$containers = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$c = json_decode($line, true);
|
||||
if ($c) $containers[] = $c;
|
||||
}
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function vv_gpu_stats(): array {
|
||||
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
|
||||
if (!$out) return ['available' => false];
|
||||
|
||||
$parts = array_map('trim', explode(',', $out));
|
||||
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
|
||||
return [
|
||||
'available' => true,
|
||||
'name' => $parts[0] ?? '',
|
||||
'memory_used' => (int)($parts[1] ?? 0),
|
||||
'memory_total' => (int)($parts[2] ?? 0),
|
||||
'utilization' => (int)($parts[3] ?? 0),
|
||||
'temperature' => (int)($parts[4] ?? 0),
|
||||
'power_w' => $power,
|
||||
'enc_pct' => (int)($parts[6] ?? 0),
|
||||
'dec_pct' => (int)($parts[7] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
function vv_gpu_processes(): array {
|
||||
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
|
||||
$procs = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$parts = array_map('trim', explode(',', $line));
|
||||
$procs[] = [
|
||||
'pid' => $parts[0] ?? '',
|
||||
'memory_mb' => $parts[1] ?? '',
|
||||
'name' => $parts[2] ?? '',
|
||||
];
|
||||
}
|
||||
return $procs;
|
||||
}
|
||||
|
||||
function vv_system_resources(): array {
|
||||
$mem = [];
|
||||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||||
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
|
||||
$mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
return [
|
||||
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
|
||||
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
|
||||
'cache' => vv_df('/mnt/cache'),
|
||||
];
|
||||
}
|
||||
|
||||
function vv_cpu_per_core(): array {
|
||||
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
|
||||
$raw = [];
|
||||
foreach (file('/proc/stat') ?: [] as $line) {
|
||||
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
|
||||
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
|
||||
}
|
||||
|
||||
$stateFile = '/tmp/vv_cpu_stat.json';
|
||||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||||
// Atomic write — concurrent fast/slow polls read a consistent snapshot
|
||||
$tmp = $stateFile . '.tmp';
|
||||
file_put_contents($tmp, json_encode($raw));
|
||||
rename($tmp, $stateFile);
|
||||
|
||||
$usage = function(array $c, ?array $p): int {
|
||||
if (!$p) return 0;
|
||||
$dt = array_sum($c) - array_sum($p);
|
||||
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
|
||||
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
|
||||
};
|
||||
|
||||
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
|
||||
$cores = [];
|
||||
foreach ($raw as $cpu => $c) {
|
||||
if ($cpu === 'cpu') continue;
|
||||
$num = (int)substr($cpu, 3);
|
||||
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
|
||||
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
|
||||
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
|
||||
$cores[] = [
|
||||
'core' => $num,
|
||||
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
|
||||
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
|
||||
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
|
||||
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
|
||||
];
|
||||
}
|
||||
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
|
||||
return ['overall' => $overall, 'cores' => $cores];
|
||||
}
|
||||
|
||||
function vv_memory_breakdown(): array {
|
||||
$mem = [];
|
||||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
$totalKb = $mem['MemTotal'] ?? 0;
|
||||
|
||||
// ZFS ARC
|
||||
$arcKb = 0;
|
||||
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
|
||||
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
|
||||
}
|
||||
|
||||
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
|
||||
$dockerKb = 0;
|
||||
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
|
||||
foreach (explode("\n", trim($dsOut)) as $line) {
|
||||
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
|
||||
$val = (float)$m[1];
|
||||
$dockerKb += match($m[2]) {
|
||||
'GiB' => (int)($val * 1048576),
|
||||
'MiB' => (int)($val * 1024),
|
||||
'KiB' => (int)$val,
|
||||
default => (int)($val / 1024),
|
||||
};
|
||||
}
|
||||
|
||||
// VM (QEMU/KVM RSS)
|
||||
$vmKb = 0;
|
||||
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
|
||||
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
|
||||
}
|
||||
|
||||
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
|
||||
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
|
||||
|
||||
// Top processes by RSS — group same-named procs, take top 5
|
||||
$grouped = [];
|
||||
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
|
||||
foreach (explode("\n", trim($psOut)) as $line) {
|
||||
$parts = preg_split('/\s+/', trim($line), 2);
|
||||
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
|
||||
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
|
||||
}
|
||||
arsort($grouped);
|
||||
$topProcs = [];
|
||||
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
|
||||
$topProcs[] = ['name' => $name, 'kb' => $kb];
|
||||
|
||||
// Swap — from API metrics when available, else /proc/meminfo
|
||||
$swapTotalKb = 0; $swapUsedKb = 0;
|
||||
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
|
||||
if (!empty($apiMem['swapTotal'])) {
|
||||
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
|
||||
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
|
||||
} else {
|
||||
$swapTotalKb = $mem['SwapTotal'] ?? 0;
|
||||
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'total_kb' => $totalKb,
|
||||
'system_kb' => $systemKb,
|
||||
'vm_kb' => $vmKb,
|
||||
'zfs_kb' => $arcKb,
|
||||
'docker_kb' => $dockerKb,
|
||||
'free_kb' => $freeKb,
|
||||
'swap_total_kb' => $swapTotalKb,
|
||||
'swap_used_kb' => $swapUsedKb,
|
||||
'top_procs' => $topProcs,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_df(string $path): array {
|
||||
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
|
||||
if (!$out) return ['available' => false, 'path' => $path];
|
||||
$parts = preg_split('/\s+/', trim($out));
|
||||
return [
|
||||
'available' => true,
|
||||
'path' => $path,
|
||||
'size_mb' => (int)$parts[0],
|
||||
'used_mb' => (int)$parts[1],
|
||||
'free_mb' => (int)$parts[2],
|
||||
];
|
||||
}
|
||||
|
||||
function vv_network_stats(): array {
|
||||
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
|
||||
if (!$iface) {
|
||||
$best = ''; $bestBytes = 0;
|
||||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||||
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
|
||||
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
|
||||
}
|
||||
$iface = $best;
|
||||
}
|
||||
if (!$iface) return ['available' => false];
|
||||
|
||||
$rxBytes = $txBytes = 0;
|
||||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||||
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
|
||||
$parts = preg_split('/\s+/', trim($m[1]));
|
||||
$rxBytes = (int)($parts[0] ?? 0);
|
||||
$txBytes = (int)($parts[8] ?? 0);
|
||||
break;
|
||||
}
|
||||
|
||||
$stateFile = '/tmp/vv_net_stat.json';
|
||||
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
|
||||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||||
$tmp = $stateFile . '.tmp';
|
||||
file_put_contents($tmp, json_encode($now));
|
||||
rename($tmp, $stateFile);
|
||||
|
||||
$rxRate = $txRate = 0;
|
||||
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
|
||||
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
|
||||
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
|
||||
}
|
||||
|
||||
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
|
||||
|
||||
// Local IP — use primary iface
|
||||
$localIp = trim(shell_exec(
|
||||
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
|
||||
) ?: '');
|
||||
|
||||
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
|
||||
$extIp = '';
|
||||
$extData = vv_cache_read('ext_ip', 300);
|
||||
if ($extData) {
|
||||
$extIp = $extData['ip'] ?? '';
|
||||
} else {
|
||||
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
|
||||
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
|
||||
$extIp = $fetched;
|
||||
vv_cache_write('ext_ip', ['ip' => $extIp]);
|
||||
}
|
||||
}
|
||||
|
||||
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
|
||||
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'iface' => $iface,
|
||||
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
|
||||
'rx_bps' => $rxRate,
|
||||
'tx_bps' => $txRate,
|
||||
'local_ip' => $localIp,
|
||||
'ext_ip' => $extIp,
|
||||
'ts_ip' => $tsIp,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
|
||||
$name = $d['name'] ?? $key;
|
||||
$isParity = $role === 'parity';
|
||||
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
|
||||
// Parity has no filesystem — use raw size only
|
||||
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
|
||||
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
|
||||
if ($size_kb <= 0) return null;
|
||||
$tempRaw = trim($d['temp'] ?? '');
|
||||
return [
|
||||
'name' => $name,
|
||||
'device' => $d['device'] ?? $key,
|
||||
'role' => $role,
|
||||
'size_gb' => round($size_kb / 1048576, 1),
|
||||
'used_gb' => round($used_kb / 1048576, 1),
|
||||
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
|
||||
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
|
||||
'transport' => $d['transport'] ?? 'ata',
|
||||
'mounted' => $mounted,
|
||||
'status' => $d['status'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
function vv_ups_stats(): array {
|
||||
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
|
||||
if (!$raw) return ['available' => false];
|
||||
|
||||
$fields = [];
|
||||
foreach (explode("\n", $raw) as $line) {
|
||||
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
|
||||
$fields[trim($m[1])] = trim($m[2]);
|
||||
}
|
||||
}
|
||||
if (empty($fields)) return ['available' => false];
|
||||
|
||||
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
|
||||
|
||||
$loadPct = $parse_num('LOADPCT');
|
||||
$nomPower = $parse_num('NOMPOWER');
|
||||
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'model' => $fields['MODEL'] ?? '',
|
||||
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
|
||||
'line_v' => $parse_num('LINEV'),
|
||||
'output_v' => $parse_num('OUTPUTV'),
|
||||
'load_pct' => $loadPct,
|
||||
'nom_power' => $nomPower,
|
||||
'watts' => $watts,
|
||||
'bcharge' => $parse_num('BCHARGE'),
|
||||
'timeleft' => $parse_num('TIMELEFT'),
|
||||
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
|
||||
'on_batt_s' => $parse_num('CUMONBATT'),
|
||||
'selftest' => $fields['SELFTEST'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
function vv_parity_status(): array {
|
||||
$var = [];
|
||||
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
|
||||
}
|
||||
|
||||
$isValid = ($var['mdNumInvalid'] ?? '0') === '0';
|
||||
$exitCode = (int)($var['sbSyncExit'] ?? 0);
|
||||
$errors = (int)($var['sbSyncErrs'] ?? 0);
|
||||
$inProgress = ($var['mdResync'] ?? '0') !== '0';
|
||||
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
|
||||
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
|
||||
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
|
||||
|
||||
// Last check from log
|
||||
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
|
||||
$logFile = '/boot/config/parity-checks.log';
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
if ($lines) {
|
||||
$p = explode('|', trim(end($lines)));
|
||||
$lastDate = trim($p[0] ?? '');
|
||||
$lastDuration = (int)($p[1] ?? 0);
|
||||
$lastSpeed = (int)($p[2] ?? 0);
|
||||
$lastExit = (int)($p[3] ?? 0);
|
||||
$lastErrors = (int)($p[4] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse last date string to timestamp
|
||||
$lastTs = $lastDate ? strtotime($lastDate) : null;
|
||||
|
||||
// Next scheduled check from cron
|
||||
$nextTs = null;
|
||||
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
|
||||
foreach (@file($cronFile) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
if (!str_contains($line, 'mdcmd')) continue;
|
||||
$p = preg_split('/\s+/', $line);
|
||||
// cron: min hour dom month dow command...
|
||||
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
|
||||
$next = new DateTime('now');
|
||||
$next->setTime((int)$p[1], (int)$p[0], 0);
|
||||
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
|
||||
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
|
||||
$nextTs = $next->getTimestamp();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
|
||||
return [
|
||||
'valid' => $isValid,
|
||||
'in_progress' => $inProgress,
|
||||
'resync_pct' => $resyncPct,
|
||||
'exit_code' => $exitCode,
|
||||
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
|
||||
'errors' => $lastErrors,
|
||||
'last_date' => $lastDate,
|
||||
'last_ts' => $lastTs,
|
||||
'last_duration' => $lastDuration,
|
||||
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
|
||||
'next_ts' => $nextTs,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_storage_pools(): array {
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api && isset($api['array']['caches'])) {
|
||||
$out = [];
|
||||
foreach ($api['array']['caches'] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'data');
|
||||
if ($entry) $out[] = $entry;
|
||||
}
|
||||
if (!empty($out)) {
|
||||
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('storage_pools');
|
||||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||||
$out = [];
|
||||
foreach ($ini as $key => $d) {
|
||||
if (($d['type'] ?? '') !== 'Cache') continue;
|
||||
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
|
||||
$entry = vv_disk_entry($d, $key);
|
||||
if ($entry) $out[] = $entry;
|
||||
}
|
||||
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
function vv_array_disks(): array {
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
|
||||
$parity = [];
|
||||
$data = [];
|
||||
foreach ($api['array']['parities'] ?? [] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'parity');
|
||||
if ($entry) $parity[] = $entry;
|
||||
}
|
||||
foreach ($api['array']['disks'] ?? [] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'data');
|
||||
if ($entry) $data[] = $entry;
|
||||
}
|
||||
if (!empty($parity) || !empty($data)) {
|
||||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
return array_merge($parity, $data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('array_disks');
|
||||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||||
$parity = [];
|
||||
$data = [];
|
||||
foreach ($ini as $key => $d) {
|
||||
$type = $d['type'] ?? '';
|
||||
if ($type === 'Parity') {
|
||||
$entry = vv_disk_entry($d, $key, 'parity');
|
||||
if ($entry) $parity[] = $entry;
|
||||
} elseif ($type === 'Data') {
|
||||
$entry = vv_disk_entry($d, $key, 'data');
|
||||
if ($entry) $data[] = $entry;
|
||||
}
|
||||
}
|
||||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
return array_merge($parity, $data);
|
||||
}
|
||||
|
||||
function vv_disk_io_rates(): array {
|
||||
$snapFile = '/tmp/vv_diskio_snap.json';
|
||||
$now = microtime(true);
|
||||
|
||||
// Read current whole-disk stats from /proc/diskstats
|
||||
$current = [];
|
||||
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||
$p = preg_split('/\s+/', trim($line));
|
||||
if (count($p) < 14) continue;
|
||||
$dev = $p[2];
|
||||
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
|
||||
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
|
||||
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
|
||||
}
|
||||
|
||||
// Load previous snapshot
|
||||
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
|
||||
$prevTime = (float)($snap['t'] ?? $now);
|
||||
$prev = $snap['d'] ?? [];
|
||||
|
||||
// Save current snapshot
|
||||
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$dt = max(0.5, $now - $prevTime);
|
||||
$out = [];
|
||||
foreach ($current as $dev => [$rs, $ws]) {
|
||||
$entry = [
|
||||
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
|
||||
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
|
||||
];
|
||||
if (isset($prev[$dev])) {
|
||||
[$prs, $pws] = $prev[$dev];
|
||||
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
|
||||
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
|
||||
if ($r > 0.01) $entry['r'] = round($r, 1);
|
||||
if ($w > 0.01) $entry['w'] = round($w, 1);
|
||||
}
|
||||
$out[$dev] = $entry;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function vv_disk_thresholds(): array {
|
||||
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
|
||||
$get = function(string $key) use ($cfg): ?int {
|
||||
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
|
||||
? (int)$m[1] : null;
|
||||
};
|
||||
return [
|
||||
'util_warn' => $get('warning') ?? 70,
|
||||
'util_crit' => $get('critical') ?? 90,
|
||||
'hdd_warn' => $get('hot') ?? 45,
|
||||
'hdd_crit' => $get('max') ?? 55,
|
||||
'ssd_warn' => $get('hotssd') ?? 60,
|
||||
'ssd_crit' => $get('maxssd') ?? 70,
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch a lightweight snapshot from each remote host that has an API key configured.
|
||||
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
|
||||
function vv_remote_hosts_stats(): array {
|
||||
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
|
||||
$vars = vv_conf_vars();
|
||||
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
|
||||
$raw = file_get_contents($f) ?: '';
|
||||
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
||||
foreach ($m[1] as $i => $key) {
|
||||
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
|
||||
}
|
||||
}
|
||||
$myHost = vv_detect_host();
|
||||
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
|
||||
sort($hostIds);
|
||||
|
||||
$results = [];
|
||||
foreach ($hostIds as $id) {
|
||||
if (strtolower($id) === strtolower($myHost)) continue;
|
||||
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
|
||||
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
|
||||
if (file_exists($bgCache)) {
|
||||
$cached = json_decode(file_get_contents($bgCache), true);
|
||||
if ($cached) {
|
||||
$cached['cache_age'] = time() - (int)filemtime($bgCache);
|
||||
$results[$id] = $cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No background cache yet — fall back to live call (uses 30s inline cache).
|
||||
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
|
||||
if (!$key) {
|
||||
$results[$id] = ['available' => false, 'no_api_key' => true,
|
||||
'host_id' => $id, 'hostname' => $vars[$id]];
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheFile = "/tmp/vv_remote_{$id}.json";
|
||||
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
|
||||
$cached = json_decode(file_get_contents($cacheFile), true);
|
||||
if ($cached) { $results[$id] = $cached; continue; }
|
||||
}
|
||||
|
||||
$gql = '{
|
||||
info { os { hostname uptime release } cpu { brand threads cores } }
|
||||
metrics { cpu { percentTotal } memory { percentTotal total used available } }
|
||||
array {
|
||||
state
|
||||
disks { fsSize fsUsed temp }
|
||||
caches { fsSize fsUsed temp }
|
||||
parities { temp }
|
||||
}
|
||||
vms { domains { name } }
|
||||
}';
|
||||
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
|
||||
|
||||
if (!$data) {
|
||||
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
|
||||
file_put_contents($cacheFile, json_encode($entry));
|
||||
$results[$id] = $entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
$os = $data['info']['os'] ?? [];
|
||||
$cpu = $data['info']['cpu'] ?? [];
|
||||
$mMem = $data['metrics']['memory'] ?? [];
|
||||
|
||||
$memPct = round((float)($mMem['percentTotal'] ?? 0));
|
||||
if ($memPct === 0) {
|
||||
$totalBytes = (float)($mMem['total'] ?? 0);
|
||||
$availBytes = (float)($mMem['available'] ?? 0);
|
||||
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
|
||||
}
|
||||
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
|
||||
|
||||
$uptimeRaw = $os['uptime'] ?? '';
|
||||
if (is_numeric($uptimeRaw)) {
|
||||
$uptimeSec = (int)$uptimeRaw;
|
||||
$days = intdiv($uptimeSec, 86400);
|
||||
$hours = intdiv($uptimeSec % 86400, 3600);
|
||||
$mins = intdiv($uptimeSec % 3600, 60);
|
||||
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
|
||||
} else {
|
||||
$uptimeSec = 0;
|
||||
$uptime = $uptimeRaw ?: '—';
|
||||
}
|
||||
|
||||
$nodeMetrics = vv_api_node_metrics($data);
|
||||
$entry = array_merge([
|
||||
'available' => true,
|
||||
'host_id' => $id,
|
||||
'hostname' => $os['hostname'] ?? $vars[$id],
|
||||
'version' => $os['release'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
|
||||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||||
'mem_total_gb' => $memTotalGb,
|
||||
'mem_used_pct' => $memPct,
|
||||
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
|
||||
], $nodeMetrics);
|
||||
file_put_contents($cacheFile, json_encode($entry));
|
||||
$results[$id] = $entry;
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
function vv_log_tail(string $path, int $lines): string {
|
||||
$fp = @fopen($path, 'r');
|
||||
if (!$fp) return '';
|
||||
fseek($fp, 0, SEEK_END);
|
||||
$size = ftell($fp);
|
||||
if ($size <= 0) { fclose($fp); return ''; }
|
||||
$chunk = min($size, 4096);
|
||||
fseek($fp, -$chunk, SEEK_END);
|
||||
$data = fread($fp, $chunk);
|
||||
fclose($fp);
|
||||
$all = explode("\n", $data ?: '');
|
||||
return implode("\n", array_slice($all, -$lines));
|
||||
}
|
||||
|
||||
function vv_parse_bash_array(string $raw, string $varName): array {
|
||||
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
|
||||
$items = [];
|
||||
foreach (explode("\n", $m[1]) as $line) {
|
||||
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
|
||||
if ($line !== '') $items[] = $line;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
function vv_transcode_sessions(): array {
|
||||
$v = vv_conf_vars();
|
||||
$stateDir = rtrim($v['STATE_DIR'] ?? '/boot/config/plugins/varaverk/State_Files', '/');
|
||||
$stateFile = $v['TRANSCODE_STATE_FILE'] ?? "$stateDir/transcode_state.db";
|
||||
if (!file_exists($stateFile)) return ['available' => false];
|
||||
|
||||
$raw = [];
|
||||
foreach (file($stateFile) ?: [] as $line) {
|
||||
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
|
||||
$raw[trim($k)] = trim($v);
|
||||
}
|
||||
|
||||
$target = $raw['current_target'] ?? '';
|
||||
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
|
||||
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
|
||||
$isRamdisk = str_contains($target, 'ramdisk');
|
||||
|
||||
// Count active session dirs in both known locations
|
||||
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
|
||||
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
|
||||
|
||||
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
|
||||
$ssdPath = '';
|
||||
$ssdSessions = 0;
|
||||
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
|
||||
$parts = explode('/', rtrim($p, '/'));
|
||||
array_pop($parts);
|
||||
$mount = implode('/', $parts) ?: '/';
|
||||
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
|
||||
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
|
||||
$ssdPath = $p;
|
||||
break;
|
||||
}
|
||||
$ssd = ['available' => false];
|
||||
if ($ssdPath) {
|
||||
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
|
||||
$parts = explode('/', rtrim($ssdPath, '/'));
|
||||
array_pop($parts);
|
||||
$ssdMount = implode('/', $parts) ?: '/';
|
||||
$ssd = vv_df($ssdMount);
|
||||
}
|
||||
|
||||
// Ramdisk disk usage
|
||||
$rd = vv_df('/mnt/ramdisk_transcodes');
|
||||
|
||||
// Last cleanup values from transcode management log
|
||||
$lastRdFreed = null;
|
||||
$lastSsdFreed = null;
|
||||
$logFile = '/var/log/varaverk/Orchestrators/transcode_management.log';
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach (array_reverse($lines) as $line) {
|
||||
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
|
||||
$lastRdFreed = $m[1];
|
||||
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
|
||||
$lastSsdFreed = $m[1];
|
||||
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'current_target' => $target,
|
||||
'is_ramdisk' => $isRamdisk,
|
||||
'flip_count_hour' => $flipCount,
|
||||
'last_flip_time' => $lastFlip,
|
||||
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
|
||||
'ram_sessions' => $ramSessions,
|
||||
'ssd_sessions' => $ssdSessions,
|
||||
'ramdisk' => $rd,
|
||||
'ssd' => $ssd,
|
||||
'last_rd_freed' => $lastRdFreed,
|
||||
'last_ssd_freed' => $lastSsdFreed,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/unraid_api.php';
|
||||
|
||||
// Common helpers shared across all Varaverk pages.
|
||||
|
||||
function vv_system_info(): array {
|
||||
// ── Shared local reads (always needed regardless of API) ──────────────────
|
||||
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
|
||||
$var = [];
|
||||
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
|
||||
}
|
||||
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
|
||||
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api) {
|
||||
$os = $api['info']['os'] ?? [];
|
||||
$cpu = $api['info']['cpu'] ?? [];
|
||||
|
||||
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
|
||||
$uptimeRaw = $os['uptime'] ?? '';
|
||||
if (is_numeric($uptimeRaw)) {
|
||||
$uptimeSec = (int)$uptimeRaw;
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
} else {
|
||||
$uptimeSec = 0;
|
||||
$uptime = $uptimeRaw ?: '—';
|
||||
}
|
||||
|
||||
$load = sys_getloadavg();
|
||||
return [
|
||||
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
|
||||
'comment' => $ident['COMMENT'] ?? '',
|
||||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||||
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
|
||||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||||
'cpu_cores' => (int)($cpu['cores'] ?? 0),
|
||||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||||
'reg_to' => $var['regTo'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
|
||||
'version' => trim($os['release'] ?? '') ?: $version,
|
||||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('system_info');
|
||||
$cpuModel = '';
|
||||
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
|
||||
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
|
||||
}
|
||||
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
|
||||
$load = sys_getloadavg();
|
||||
return [
|
||||
'name' => $ident['NAME'] ?? gethostname(),
|
||||
'comment' => $ident['COMMENT'] ?? '',
|
||||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||||
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
|
||||
'cpu_threads' => 0,
|
||||
'cpu_cores' => 0,
|
||||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||||
'reg_to' => $var['regTo'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'array_state' => $var['mdState'] ?? 'UNKNOWN',
|
||||
'version' => $version,
|
||||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_docker_containers(): array {
|
||||
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
|
||||
$containers = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$c = json_decode($line, true);
|
||||
if ($c) $containers[] = $c;
|
||||
}
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function vv_docker_stopped(): array {
|
||||
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
|
||||
$containers = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$c = json_decode($line, true);
|
||||
if ($c) $containers[] = $c;
|
||||
}
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function vv_gpu_stats(): array {
|
||||
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
|
||||
if (!$out) return ['available' => false];
|
||||
|
||||
$parts = array_map('trim', explode(',', $out));
|
||||
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
|
||||
return [
|
||||
'available' => true,
|
||||
'name' => $parts[0] ?? '',
|
||||
'memory_used' => (int)($parts[1] ?? 0),
|
||||
'memory_total' => (int)($parts[2] ?? 0),
|
||||
'utilization' => (int)($parts[3] ?? 0),
|
||||
'temperature' => (int)($parts[4] ?? 0),
|
||||
'power_w' => $power,
|
||||
'enc_pct' => (int)($parts[6] ?? 0),
|
||||
'dec_pct' => (int)($parts[7] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
function vv_gpu_processes(): array {
|
||||
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
|
||||
$procs = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$parts = array_map('trim', explode(',', $line));
|
||||
$procs[] = [
|
||||
'pid' => $parts[0] ?? '',
|
||||
'memory_mb' => $parts[1] ?? '',
|
||||
'name' => $parts[2] ?? '',
|
||||
];
|
||||
}
|
||||
return $procs;
|
||||
}
|
||||
|
||||
function vv_system_resources(): array {
|
||||
$mem = [];
|
||||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||||
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
|
||||
$mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
return [
|
||||
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
|
||||
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
|
||||
'cache' => vv_df('/mnt/cache'),
|
||||
];
|
||||
}
|
||||
|
||||
function vv_cpu_per_core(): array {
|
||||
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
|
||||
$raw = [];
|
||||
foreach (file('/proc/stat') ?: [] as $line) {
|
||||
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
|
||||
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
|
||||
}
|
||||
|
||||
$stateFile = '/tmp/vv_cpu_stat.json';
|
||||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||||
// Atomic write — concurrent fast/slow polls read a consistent snapshot
|
||||
$tmp = $stateFile . '.tmp';
|
||||
file_put_contents($tmp, json_encode($raw));
|
||||
rename($tmp, $stateFile);
|
||||
|
||||
$usage = function(array $c, ?array $p): int {
|
||||
if (!$p) return 0;
|
||||
$dt = array_sum($c) - array_sum($p);
|
||||
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
|
||||
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
|
||||
};
|
||||
|
||||
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
|
||||
$cores = [];
|
||||
foreach ($raw as $cpu => $c) {
|
||||
if ($cpu === 'cpu') continue;
|
||||
$num = (int)substr($cpu, 3);
|
||||
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
|
||||
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
|
||||
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
|
||||
$cores[] = [
|
||||
'core' => $num,
|
||||
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
|
||||
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
|
||||
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
|
||||
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
|
||||
];
|
||||
}
|
||||
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
|
||||
return ['overall' => $overall, 'cores' => $cores];
|
||||
}
|
||||
|
||||
function vv_memory_breakdown(): array {
|
||||
$mem = [];
|
||||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
$totalKb = $mem['MemTotal'] ?? 0;
|
||||
|
||||
// ZFS ARC
|
||||
$arcKb = 0;
|
||||
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
|
||||
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
|
||||
}
|
||||
|
||||
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
|
||||
$dockerKb = 0;
|
||||
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
|
||||
foreach (explode("\n", trim($dsOut)) as $line) {
|
||||
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
|
||||
$val = (float)$m[1];
|
||||
$dockerKb += match($m[2]) {
|
||||
'GiB' => (int)($val * 1048576),
|
||||
'MiB' => (int)($val * 1024),
|
||||
'KiB' => (int)$val,
|
||||
default => (int)($val / 1024),
|
||||
};
|
||||
}
|
||||
|
||||
// VM (QEMU/KVM RSS)
|
||||
$vmKb = 0;
|
||||
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
|
||||
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
|
||||
}
|
||||
|
||||
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
|
||||
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
|
||||
|
||||
// Top processes by RSS — group same-named procs, take top 5
|
||||
$grouped = [];
|
||||
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
|
||||
foreach (explode("\n", trim($psOut)) as $line) {
|
||||
$parts = preg_split('/\s+/', trim($line), 2);
|
||||
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
|
||||
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
|
||||
}
|
||||
arsort($grouped);
|
||||
$topProcs = [];
|
||||
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
|
||||
$topProcs[] = ['name' => $name, 'kb' => $kb];
|
||||
|
||||
// Swap — from API metrics when available, else /proc/meminfo
|
||||
$swapTotalKb = 0; $swapUsedKb = 0;
|
||||
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
|
||||
if (!empty($apiMem['swapTotal'])) {
|
||||
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
|
||||
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
|
||||
} else {
|
||||
$swapTotalKb = $mem['SwapTotal'] ?? 0;
|
||||
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'total_kb' => $totalKb,
|
||||
'system_kb' => $systemKb,
|
||||
'vm_kb' => $vmKb,
|
||||
'zfs_kb' => $arcKb,
|
||||
'docker_kb' => $dockerKb,
|
||||
'free_kb' => $freeKb,
|
||||
'swap_total_kb' => $swapTotalKb,
|
||||
'swap_used_kb' => $swapUsedKb,
|
||||
'top_procs' => $topProcs,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_df(string $path): array {
|
||||
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
|
||||
if (!$out) return ['available' => false, 'path' => $path];
|
||||
$parts = preg_split('/\s+/', trim($out));
|
||||
return [
|
||||
'available' => true,
|
||||
'path' => $path,
|
||||
'size_mb' => (int)$parts[0],
|
||||
'used_mb' => (int)$parts[1],
|
||||
'free_mb' => (int)$parts[2],
|
||||
];
|
||||
}
|
||||
|
||||
function vv_network_stats(): array {
|
||||
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
|
||||
if (!$iface) {
|
||||
$best = ''; $bestBytes = 0;
|
||||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||||
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
|
||||
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
|
||||
}
|
||||
$iface = $best;
|
||||
}
|
||||
if (!$iface) return ['available' => false];
|
||||
|
||||
$rxBytes = $txBytes = 0;
|
||||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||||
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
|
||||
$parts = preg_split('/\s+/', trim($m[1]));
|
||||
$rxBytes = (int)($parts[0] ?? 0);
|
||||
$txBytes = (int)($parts[8] ?? 0);
|
||||
break;
|
||||
}
|
||||
|
||||
$stateFile = '/tmp/vv_net_stat.json';
|
||||
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
|
||||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||||
$tmp = $stateFile . '.tmp';
|
||||
file_put_contents($tmp, json_encode($now));
|
||||
rename($tmp, $stateFile);
|
||||
|
||||
$rxRate = $txRate = 0;
|
||||
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
|
||||
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
|
||||
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
|
||||
}
|
||||
|
||||
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
|
||||
|
||||
// Local IP — use primary iface
|
||||
$localIp = trim(shell_exec(
|
||||
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
|
||||
) ?: '');
|
||||
|
||||
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
|
||||
$extIp = '';
|
||||
$extData = vv_cache_read('ext_ip', 300);
|
||||
if ($extData) {
|
||||
$extIp = $extData['ip'] ?? '';
|
||||
} else {
|
||||
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
|
||||
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
|
||||
$extIp = $fetched;
|
||||
vv_cache_write('ext_ip', ['ip' => $extIp]);
|
||||
}
|
||||
}
|
||||
|
||||
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
|
||||
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'iface' => $iface,
|
||||
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
|
||||
'rx_bps' => $rxRate,
|
||||
'tx_bps' => $txRate,
|
||||
'local_ip' => $localIp,
|
||||
'ext_ip' => $extIp,
|
||||
'ts_ip' => $tsIp,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
|
||||
$name = $d['name'] ?? $key;
|
||||
$isParity = $role === 'parity';
|
||||
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
|
||||
// Parity has no filesystem — use raw size only
|
||||
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
|
||||
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
|
||||
if ($size_kb <= 0) return null;
|
||||
$tempRaw = trim($d['temp'] ?? '');
|
||||
return [
|
||||
'name' => $name,
|
||||
'device' => $d['device'] ?? $key,
|
||||
'role' => $role,
|
||||
'size_gb' => round($size_kb / 1048576, 1),
|
||||
'used_gb' => round($used_kb / 1048576, 1),
|
||||
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
|
||||
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
|
||||
'transport' => $d['transport'] ?? 'ata',
|
||||
'mounted' => $mounted,
|
||||
'status' => $d['status'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
function vv_ups_stats(): array {
|
||||
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
|
||||
if (!$raw) return ['available' => false];
|
||||
|
||||
$fields = [];
|
||||
foreach (explode("\n", $raw) as $line) {
|
||||
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
|
||||
$fields[trim($m[1])] = trim($m[2]);
|
||||
}
|
||||
}
|
||||
if (empty($fields)) return ['available' => false];
|
||||
|
||||
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
|
||||
|
||||
$loadPct = $parse_num('LOADPCT');
|
||||
$nomPower = $parse_num('NOMPOWER');
|
||||
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'model' => $fields['MODEL'] ?? '',
|
||||
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
|
||||
'line_v' => $parse_num('LINEV'),
|
||||
'output_v' => $parse_num('OUTPUTV'),
|
||||
'load_pct' => $loadPct,
|
||||
'nom_power' => $nomPower,
|
||||
'watts' => $watts,
|
||||
'bcharge' => $parse_num('BCHARGE'),
|
||||
'timeleft' => $parse_num('TIMELEFT'),
|
||||
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
|
||||
'on_batt_s' => $parse_num('CUMONBATT'),
|
||||
'selftest' => $fields['SELFTEST'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
function vv_parity_status(): array {
|
||||
$var = [];
|
||||
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
|
||||
}
|
||||
|
||||
$isValid = ($var['mdNumInvalid'] ?? '0') === '0';
|
||||
$exitCode = (int)($var['sbSyncExit'] ?? 0);
|
||||
$errors = (int)($var['sbSyncErrs'] ?? 0);
|
||||
$inProgress = ($var['mdResync'] ?? '0') !== '0';
|
||||
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
|
||||
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
|
||||
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
|
||||
|
||||
// Last check from log
|
||||
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
|
||||
$logFile = '/boot/config/parity-checks.log';
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
if ($lines) {
|
||||
$p = explode('|', trim(end($lines)));
|
||||
$lastDate = trim($p[0] ?? '');
|
||||
$lastDuration = (int)($p[1] ?? 0);
|
||||
$lastSpeed = (int)($p[2] ?? 0);
|
||||
$lastExit = (int)($p[3] ?? 0);
|
||||
$lastErrors = (int)($p[4] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse last date string to timestamp
|
||||
$lastTs = $lastDate ? strtotime($lastDate) : null;
|
||||
|
||||
// Next scheduled check from cron
|
||||
$nextTs = null;
|
||||
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
|
||||
foreach (@file($cronFile) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
if (!str_contains($line, 'mdcmd')) continue;
|
||||
$p = preg_split('/\s+/', $line);
|
||||
// cron: min hour dom month dow command...
|
||||
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
|
||||
$next = new DateTime('now');
|
||||
$next->setTime((int)$p[1], (int)$p[0], 0);
|
||||
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
|
||||
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
|
||||
$nextTs = $next->getTimestamp();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
|
||||
return [
|
||||
'valid' => $isValid,
|
||||
'in_progress' => $inProgress,
|
||||
'resync_pct' => $resyncPct,
|
||||
'exit_code' => $exitCode,
|
||||
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
|
||||
'errors' => $lastErrors,
|
||||
'last_date' => $lastDate,
|
||||
'last_ts' => $lastTs,
|
||||
'last_duration' => $lastDuration,
|
||||
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
|
||||
'next_ts' => $nextTs,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_storage_pools(): array {
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api && isset($api['array']['caches'])) {
|
||||
$out = [];
|
||||
foreach ($api['array']['caches'] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'data');
|
||||
if ($entry) $out[] = $entry;
|
||||
}
|
||||
if (!empty($out)) {
|
||||
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('storage_pools');
|
||||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||||
$out = [];
|
||||
foreach ($ini as $key => $d) {
|
||||
if (($d['type'] ?? '') !== 'Cache') continue;
|
||||
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
|
||||
$entry = vv_disk_entry($d, $key);
|
||||
if ($entry) $out[] = $entry;
|
||||
}
|
||||
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
function vv_array_disks(): array {
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
|
||||
$parity = [];
|
||||
$data = [];
|
||||
foreach ($api['array']['parities'] ?? [] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'parity');
|
||||
if ($entry) $parity[] = $entry;
|
||||
}
|
||||
foreach ($api['array']['disks'] ?? [] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'data');
|
||||
if ($entry) $data[] = $entry;
|
||||
}
|
||||
if (!empty($parity) || !empty($data)) {
|
||||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
return array_merge($parity, $data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('array_disks');
|
||||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||||
$parity = [];
|
||||
$data = [];
|
||||
foreach ($ini as $key => $d) {
|
||||
$type = $d['type'] ?? '';
|
||||
if ($type === 'Parity') {
|
||||
$entry = vv_disk_entry($d, $key, 'parity');
|
||||
if ($entry) $parity[] = $entry;
|
||||
} elseif ($type === 'Data') {
|
||||
$entry = vv_disk_entry($d, $key, 'data');
|
||||
if ($entry) $data[] = $entry;
|
||||
}
|
||||
}
|
||||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
return array_merge($parity, $data);
|
||||
}
|
||||
|
||||
function vv_disk_io_rates(): array {
|
||||
$snapFile = '/tmp/vv_diskio_snap.json';
|
||||
$now = microtime(true);
|
||||
|
||||
// Read current whole-disk stats from /proc/diskstats
|
||||
$current = [];
|
||||
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||
$p = preg_split('/\s+/', trim($line));
|
||||
if (count($p) < 14) continue;
|
||||
$dev = $p[2];
|
||||
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
|
||||
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
|
||||
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
|
||||
}
|
||||
|
||||
// Load previous snapshot
|
||||
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
|
||||
$prevTime = (float)($snap['t'] ?? $now);
|
||||
$prev = $snap['d'] ?? [];
|
||||
|
||||
// Save current snapshot
|
||||
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$dt = max(0.5, $now - $prevTime);
|
||||
$out = [];
|
||||
foreach ($current as $dev => [$rs, $ws]) {
|
||||
$entry = [
|
||||
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
|
||||
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
|
||||
];
|
||||
if (isset($prev[$dev])) {
|
||||
[$prs, $pws] = $prev[$dev];
|
||||
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
|
||||
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
|
||||
if ($r > 0.01) $entry['r'] = round($r, 1);
|
||||
if ($w > 0.01) $entry['w'] = round($w, 1);
|
||||
}
|
||||
$out[$dev] = $entry;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function vv_disk_thresholds(): array {
|
||||
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
|
||||
$get = function(string $key) use ($cfg): ?int {
|
||||
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
|
||||
? (int)$m[1] : null;
|
||||
};
|
||||
return [
|
||||
'util_warn' => $get('warning') ?? 70,
|
||||
'util_crit' => $get('critical') ?? 90,
|
||||
'hdd_warn' => $get('hot') ?? 45,
|
||||
'hdd_crit' => $get('max') ?? 55,
|
||||
'ssd_warn' => $get('hotssd') ?? 60,
|
||||
'ssd_crit' => $get('maxssd') ?? 70,
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch a lightweight snapshot from each remote host that has an API key configured.
|
||||
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
|
||||
function vv_remote_hosts_stats(): array {
|
||||
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
|
||||
$vars = vv_conf_vars();
|
||||
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
|
||||
$raw = file_get_contents($f) ?: '';
|
||||
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
||||
foreach ($m[1] as $i => $key) {
|
||||
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
|
||||
}
|
||||
}
|
||||
$myHost = vv_detect_host();
|
||||
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
|
||||
sort($hostIds);
|
||||
|
||||
$results = [];
|
||||
foreach ($hostIds as $id) {
|
||||
if (strtolower($id) === strtolower($myHost)) continue;
|
||||
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
|
||||
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
|
||||
if (file_exists($bgCache)) {
|
||||
$cached = json_decode(file_get_contents($bgCache), true);
|
||||
if ($cached) {
|
||||
$cached['cache_age'] = time() - (int)filemtime($bgCache);
|
||||
$results[$id] = $cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No background cache yet — fall back to live call (uses 30s inline cache).
|
||||
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
|
||||
if (!$key) {
|
||||
$results[$id] = ['available' => false, 'no_api_key' => true,
|
||||
'host_id' => $id, 'hostname' => $vars[$id]];
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheFile = "/tmp/vv_remote_{$id}.json";
|
||||
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
|
||||
$cached = json_decode(file_get_contents($cacheFile), true);
|
||||
if ($cached) { $results[$id] = $cached; continue; }
|
||||
}
|
||||
|
||||
$gql = '{
|
||||
info { os { hostname uptime release } cpu { brand threads cores } }
|
||||
metrics { cpu { percentTotal } memory { percentTotal total used available } }
|
||||
array {
|
||||
state
|
||||
disks { fsSize fsUsed temp }
|
||||
caches { fsSize fsUsed temp }
|
||||
parities { temp }
|
||||
}
|
||||
vms { domains { name } }
|
||||
}';
|
||||
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
|
||||
|
||||
if (!$data) {
|
||||
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
|
||||
file_put_contents($cacheFile, json_encode($entry));
|
||||
$results[$id] = $entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
$os = $data['info']['os'] ?? [];
|
||||
$cpu = $data['info']['cpu'] ?? [];
|
||||
$mMem = $data['metrics']['memory'] ?? [];
|
||||
|
||||
$memPct = round((float)($mMem['percentTotal'] ?? 0));
|
||||
if ($memPct === 0) {
|
||||
$totalBytes = (float)($mMem['total'] ?? 0);
|
||||
$availBytes = (float)($mMem['available'] ?? 0);
|
||||
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
|
||||
}
|
||||
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
|
||||
|
||||
$uptimeRaw = $os['uptime'] ?? '';
|
||||
if (is_numeric($uptimeRaw)) {
|
||||
$uptimeSec = (int)$uptimeRaw;
|
||||
$days = intdiv($uptimeSec, 86400);
|
||||
$hours = intdiv($uptimeSec % 86400, 3600);
|
||||
$mins = intdiv($uptimeSec % 3600, 60);
|
||||
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
|
||||
} else {
|
||||
$uptimeSec = 0;
|
||||
$uptime = $uptimeRaw ?: '—';
|
||||
}
|
||||
|
||||
$nodeMetrics = vv_api_node_metrics($data);
|
||||
$entry = array_merge([
|
||||
'available' => true,
|
||||
'host_id' => $id,
|
||||
'hostname' => $os['hostname'] ?? $vars[$id],
|
||||
'version' => $os['release'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
|
||||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||||
'mem_total_gb' => $memTotalGb,
|
||||
'mem_used_pct' => $memPct,
|
||||
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
|
||||
], $nodeMetrics);
|
||||
file_put_contents($cacheFile, json_encode($entry));
|
||||
$results[$id] = $entry;
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
function vv_log_tail(string $path, int $lines): string {
|
||||
$fp = @fopen($path, 'r');
|
||||
if (!$fp) return '';
|
||||
fseek($fp, 0, SEEK_END);
|
||||
$size = ftell($fp);
|
||||
if ($size <= 0) { fclose($fp); return ''; }
|
||||
$chunk = min($size, 4096);
|
||||
fseek($fp, -$chunk, SEEK_END);
|
||||
$data = fread($fp, $chunk);
|
||||
fclose($fp);
|
||||
$all = explode("\n", $data ?: '');
|
||||
return implode("\n", array_slice($all, -$lines));
|
||||
}
|
||||
|
||||
function vv_parse_bash_array(string $raw, string $varName): array {
|
||||
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
|
||||
$items = [];
|
||||
foreach (explode("\n", $m[1]) as $line) {
|
||||
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
|
||||
if ($line !== '') $items[] = $line;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
function vv_transcode_sessions(): array {
|
||||
$v = vv_conf_vars();
|
||||
$stateDir = rtrim($v['STATE_DIR'] ?? '/boot/config/plugins/varaverk/State_Files', '/');
|
||||
$stateFile = "$stateDir/transcode_state.db";
|
||||
if (!file_exists($stateFile)) return ['available' => false];
|
||||
|
||||
$raw = [];
|
||||
foreach (file($stateFile) ?: [] as $line) {
|
||||
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
|
||||
$raw[trim($k)] = trim($v);
|
||||
}
|
||||
|
||||
$target = $raw['current_target'] ?? '';
|
||||
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
|
||||
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
|
||||
$isRamdisk = str_contains($target, 'ramdisk');
|
||||
|
||||
// Count active session dirs in both known locations
|
||||
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
|
||||
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
|
||||
|
||||
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
|
||||
$ssdPath = '';
|
||||
$ssdSessions = 0;
|
||||
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
|
||||
$parts = explode('/', rtrim($p, '/'));
|
||||
array_pop($parts);
|
||||
$mount = implode('/', $parts) ?: '/';
|
||||
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
|
||||
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
|
||||
$ssdPath = $p;
|
||||
break;
|
||||
}
|
||||
$ssd = ['available' => false];
|
||||
if ($ssdPath) {
|
||||
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
|
||||
$parts = explode('/', rtrim($ssdPath, '/'));
|
||||
array_pop($parts);
|
||||
$ssdMount = implode('/', $parts) ?: '/';
|
||||
$ssd = vv_df($ssdMount);
|
||||
}
|
||||
|
||||
// Ramdisk disk usage
|
||||
$rd = vv_df('/mnt/ramdisk_transcodes');
|
||||
|
||||
// Last cleanup values from transcode management log
|
||||
$lastRdFreed = null;
|
||||
$lastSsdFreed = null;
|
||||
$logFile = '/var/log/varaverk/Orchestrators/transcode_management.log';
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach (array_reverse($lines) as $line) {
|
||||
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
|
||||
$lastRdFreed = $m[1];
|
||||
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
|
||||
$lastSsdFreed = $m[1];
|
||||
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'current_target' => $target,
|
||||
'is_ramdisk' => $isRamdisk,
|
||||
'flip_count_hour' => $flipCount,
|
||||
'last_flip_time' => $lastFlip,
|
||||
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
|
||||
'ram_sessions' => $ramSessions,
|
||||
'ssd_sessions' => $ssdSessions,
|
||||
'ramdisk' => $rd,
|
||||
'ssd' => $ssd,
|
||||
'last_rd_freed' => $lastRdFreed,
|
||||
'last_ssd_freed' => $lastSsdFreed,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/unraid_api.php';
|
||||
|
||||
// Common helpers shared across all Varaverk pages.
|
||||
|
||||
function vv_system_info(): array {
|
||||
// ── Shared local reads (always needed regardless of API) ──────────────────
|
||||
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
|
||||
$var = [];
|
||||
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
|
||||
}
|
||||
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
|
||||
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api) {
|
||||
$os = $api['info']['os'] ?? [];
|
||||
$cpu = $api['info']['cpu'] ?? [];
|
||||
|
||||
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
|
||||
$uptimeRaw = $os['uptime'] ?? '';
|
||||
if (is_numeric($uptimeRaw)) {
|
||||
$uptimeSec = (int)$uptimeRaw;
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
} else {
|
||||
$uptimeSec = 0;
|
||||
$uptime = $uptimeRaw ?: '—';
|
||||
}
|
||||
|
||||
$load = sys_getloadavg();
|
||||
return [
|
||||
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
|
||||
'comment' => $ident['COMMENT'] ?? '',
|
||||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||||
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
|
||||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||||
'cpu_cores' => (int)($cpu['cores'] ?? 0),
|
||||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||||
'reg_to' => $var['regTo'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
|
||||
'version' => trim($os['release'] ?? '') ?: $version,
|
||||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('system_info');
|
||||
$cpuModel = '';
|
||||
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
|
||||
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
|
||||
}
|
||||
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
|
||||
$uptime = vv_format_uptime($uptimeSec);
|
||||
|
||||
$load = sys_getloadavg();
|
||||
return [
|
||||
'name' => $ident['NAME'] ?? gethostname(),
|
||||
'comment' => $ident['COMMENT'] ?? '',
|
||||
'timezone' => $ident['timeZone'] ?? 'UTC',
|
||||
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
|
||||
'cpu_threads' => 0,
|
||||
'cpu_cores' => 0,
|
||||
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
|
||||
'reg_to' => $var['regTo'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'array_state' => $var['mdState'] ?? 'UNKNOWN',
|
||||
'version' => $version,
|
||||
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_docker_containers(): array {
|
||||
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
|
||||
$containers = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$c = json_decode($line, true);
|
||||
if ($c) $containers[] = $c;
|
||||
}
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function vv_docker_stopped(): array {
|
||||
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
|
||||
$containers = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$c = json_decode($line, true);
|
||||
if ($c) $containers[] = $c;
|
||||
}
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function vv_gpu_stats(): array {
|
||||
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
|
||||
if (!$out) return ['available' => false];
|
||||
|
||||
$parts = array_map('trim', explode(',', $out));
|
||||
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
|
||||
return [
|
||||
'available' => true,
|
||||
'name' => $parts[0] ?? '',
|
||||
'memory_used' => (int)($parts[1] ?? 0),
|
||||
'memory_total' => (int)($parts[2] ?? 0),
|
||||
'utilization' => (int)($parts[3] ?? 0),
|
||||
'temperature' => (int)($parts[4] ?? 0),
|
||||
'power_w' => $power,
|
||||
'enc_pct' => (int)($parts[6] ?? 0),
|
||||
'dec_pct' => (int)($parts[7] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
function vv_gpu_processes(): array {
|
||||
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
|
||||
$procs = [];
|
||||
foreach (explode("\n", trim($out ?? '')) as $line) {
|
||||
if (!$line) continue;
|
||||
$parts = array_map('trim', explode(',', $line));
|
||||
$procs[] = [
|
||||
'pid' => $parts[0] ?? '',
|
||||
'memory_mb' => $parts[1] ?? '',
|
||||
'name' => $parts[2] ?? '',
|
||||
];
|
||||
}
|
||||
return $procs;
|
||||
}
|
||||
|
||||
function vv_system_resources(): array {
|
||||
$mem = [];
|
||||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||||
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
|
||||
$mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
return [
|
||||
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
|
||||
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
|
||||
'cache' => vv_df('/mnt/cache'),
|
||||
];
|
||||
}
|
||||
|
||||
function vv_cpu_per_core(): array {
|
||||
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
|
||||
$raw = [];
|
||||
foreach (file('/proc/stat') ?: [] as $line) {
|
||||
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
|
||||
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
|
||||
}
|
||||
|
||||
$stateFile = VV_CACHE_DIR . '/vv_cpu_stat.json';
|
||||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||||
// Atomic write — concurrent fast/slow polls read a consistent snapshot
|
||||
$tmp = $stateFile . '.tmp';
|
||||
file_put_contents($tmp, json_encode($raw));
|
||||
rename($tmp, $stateFile);
|
||||
|
||||
$usage = function(array $c, ?array $p): int {
|
||||
if (!$p) return 0;
|
||||
$dt = array_sum($c) - array_sum($p);
|
||||
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
|
||||
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
|
||||
};
|
||||
|
||||
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
|
||||
$cores = [];
|
||||
foreach ($raw as $cpu => $c) {
|
||||
if ($cpu === 'cpu') continue;
|
||||
$num = (int)substr($cpu, 3);
|
||||
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
|
||||
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
|
||||
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
|
||||
$cores[] = [
|
||||
'core' => $num,
|
||||
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
|
||||
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
|
||||
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
|
||||
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
|
||||
];
|
||||
}
|
||||
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
|
||||
return ['overall' => $overall, 'cores' => $cores];
|
||||
}
|
||||
|
||||
function vv_memory_breakdown(): array {
|
||||
$mem = [];
|
||||
foreach (file('/proc/meminfo') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
|
||||
}
|
||||
$totalKb = $mem['MemTotal'] ?? 0;
|
||||
|
||||
// ZFS ARC
|
||||
$arcKb = 0;
|
||||
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
|
||||
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
|
||||
}
|
||||
|
||||
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
|
||||
$dockerKb = 0;
|
||||
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
|
||||
foreach (explode("\n", trim($dsOut)) as $line) {
|
||||
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
|
||||
$val = (float)$m[1];
|
||||
$dockerKb += match($m[2]) {
|
||||
'GiB' => (int)($val * 1048576),
|
||||
'MiB' => (int)($val * 1024),
|
||||
'KiB' => (int)$val,
|
||||
default => (int)($val / 1024),
|
||||
};
|
||||
}
|
||||
|
||||
// VM (QEMU/KVM RSS)
|
||||
$vmKb = 0;
|
||||
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
|
||||
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
|
||||
}
|
||||
|
||||
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
|
||||
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
|
||||
|
||||
// Top processes by RSS — group same-named procs, take top 5
|
||||
$grouped = [];
|
||||
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
|
||||
foreach (explode("\n", trim($psOut)) as $line) {
|
||||
$parts = preg_split('/\s+/', trim($line), 2);
|
||||
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
|
||||
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
|
||||
}
|
||||
arsort($grouped);
|
||||
$topProcs = [];
|
||||
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
|
||||
$topProcs[] = ['name' => $name, 'kb' => $kb];
|
||||
|
||||
// Swap — from API metrics when available, else /proc/meminfo
|
||||
$swapTotalKb = 0; $swapUsedKb = 0;
|
||||
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
|
||||
if (!empty($apiMem['swapTotal'])) {
|
||||
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
|
||||
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
|
||||
} else {
|
||||
$swapTotalKb = $mem['SwapTotal'] ?? 0;
|
||||
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'total_kb' => $totalKb,
|
||||
'system_kb' => $systemKb,
|
||||
'vm_kb' => $vmKb,
|
||||
'zfs_kb' => $arcKb,
|
||||
'docker_kb' => $dockerKb,
|
||||
'free_kb' => $freeKb,
|
||||
'swap_total_kb' => $swapTotalKb,
|
||||
'swap_used_kb' => $swapUsedKb,
|
||||
'top_procs' => $topProcs,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_df(string $path): array {
|
||||
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
|
||||
if (!$out) return ['available' => false, 'path' => $path];
|
||||
$parts = preg_split('/\s+/', trim($out));
|
||||
return [
|
||||
'available' => true,
|
||||
'path' => $path,
|
||||
'size_mb' => (int)$parts[0],
|
||||
'used_mb' => (int)$parts[1],
|
||||
'free_mb' => (int)$parts[2],
|
||||
];
|
||||
}
|
||||
|
||||
function vv_network_stats(): array {
|
||||
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
|
||||
if (!$iface) {
|
||||
$best = ''; $bestBytes = 0;
|
||||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||||
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
|
||||
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
|
||||
}
|
||||
$iface = $best;
|
||||
}
|
||||
if (!$iface) return ['available' => false];
|
||||
|
||||
$rxBytes = $txBytes = 0;
|
||||
foreach (file('/proc/net/dev') ?: [] as $line) {
|
||||
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
|
||||
$parts = preg_split('/\s+/', trim($m[1]));
|
||||
$rxBytes = (int)($parts[0] ?? 0);
|
||||
$txBytes = (int)($parts[8] ?? 0);
|
||||
break;
|
||||
}
|
||||
|
||||
$stateFile = VV_CACHE_DIR . '/vv_net_stat.json';
|
||||
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
|
||||
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
|
||||
$tmp = $stateFile . '.tmp';
|
||||
file_put_contents($tmp, json_encode($now));
|
||||
rename($tmp, $stateFile);
|
||||
|
||||
$rxRate = $txRate = 0;
|
||||
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
|
||||
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
|
||||
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
|
||||
}
|
||||
|
||||
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
|
||||
|
||||
// Local IP — use primary iface
|
||||
$localIp = trim(shell_exec(
|
||||
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
|
||||
) ?: '');
|
||||
|
||||
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
|
||||
$extIp = '';
|
||||
$extData = vv_cache_read('ext_ip', 300);
|
||||
if ($extData) {
|
||||
$extIp = $extData['ip'] ?? '';
|
||||
} else {
|
||||
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
|
||||
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
|
||||
$extIp = $fetched;
|
||||
vv_cache_write('ext_ip', ['ip' => $extIp]);
|
||||
}
|
||||
}
|
||||
|
||||
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
|
||||
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'iface' => $iface,
|
||||
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
|
||||
'rx_bps' => $rxRate,
|
||||
'tx_bps' => $txRate,
|
||||
'local_ip' => $localIp,
|
||||
'ext_ip' => $extIp,
|
||||
'ts_ip' => $tsIp,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
|
||||
$name = $d['name'] ?? $key;
|
||||
$isParity = $role === 'parity';
|
||||
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
|
||||
// Parity has no filesystem — use raw size only
|
||||
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
|
||||
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
|
||||
if ($size_kb <= 0) return null;
|
||||
$tempRaw = trim($d['temp'] ?? '');
|
||||
return [
|
||||
'name' => $name,
|
||||
'device' => $d['device'] ?? $key,
|
||||
'role' => $role,
|
||||
'size_gb' => round($size_kb / 1048576, 1),
|
||||
'used_gb' => round($used_kb / 1048576, 1),
|
||||
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
|
||||
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
|
||||
'transport' => $d['transport'] ?? 'ata',
|
||||
'mounted' => $mounted,
|
||||
'status' => $d['status'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
function vv_ups_stats(): array {
|
||||
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
|
||||
if (!$raw) return ['available' => false];
|
||||
|
||||
$fields = [];
|
||||
foreach (explode("\n", $raw) as $line) {
|
||||
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
|
||||
$fields[trim($m[1])] = trim($m[2]);
|
||||
}
|
||||
}
|
||||
if (empty($fields)) return ['available' => false];
|
||||
|
||||
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
|
||||
|
||||
$loadPct = $parse_num('LOADPCT');
|
||||
$nomPower = $parse_num('NOMPOWER');
|
||||
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'model' => $fields['MODEL'] ?? '',
|
||||
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
|
||||
'line_v' => $parse_num('LINEV'),
|
||||
'output_v' => $parse_num('OUTPUTV'),
|
||||
'load_pct' => $loadPct,
|
||||
'nom_power' => $nomPower,
|
||||
'watts' => $watts,
|
||||
'bcharge' => $parse_num('BCHARGE'),
|
||||
'timeleft' => $parse_num('TIMELEFT'),
|
||||
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
|
||||
'on_batt_s' => $parse_num('CUMONBATT'),
|
||||
'selftest' => $fields['SELFTEST'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
function vv_parity_status(): array {
|
||||
$var = [];
|
||||
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
|
||||
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
|
||||
}
|
||||
|
||||
$isValid = ($var['mdNumInvalid'] ?? '0') === '0';
|
||||
$exitCode = (int)($var['sbSyncExit'] ?? 0);
|
||||
$errors = (int)($var['sbSyncErrs'] ?? 0);
|
||||
$inProgress = ($var['mdResync'] ?? '0') !== '0';
|
||||
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
|
||||
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
|
||||
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
|
||||
|
||||
// Last check from log
|
||||
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
|
||||
$logFile = '/boot/config/parity-checks.log';
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
if ($lines) {
|
||||
$p = explode('|', trim(end($lines)));
|
||||
$lastDate = trim($p[0] ?? '');
|
||||
$lastDuration = (int)($p[1] ?? 0);
|
||||
$lastSpeed = (int)($p[2] ?? 0);
|
||||
$lastExit = (int)($p[3] ?? 0);
|
||||
$lastErrors = (int)($p[4] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse last date string to timestamp
|
||||
$lastTs = $lastDate ? strtotime($lastDate) : null;
|
||||
|
||||
// Next scheduled check from cron
|
||||
$nextTs = null;
|
||||
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
|
||||
foreach (@file($cronFile) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
if (!str_contains($line, 'mdcmd')) continue;
|
||||
$p = preg_split('/\s+/', $line);
|
||||
// cron: min hour dom month dow command...
|
||||
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
|
||||
$next = new DateTime('now');
|
||||
$next->setTime((int)$p[1], (int)$p[0], 0);
|
||||
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
|
||||
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
|
||||
$nextTs = $next->getTimestamp();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
|
||||
return [
|
||||
'valid' => $isValid,
|
||||
'in_progress' => $inProgress,
|
||||
'resync_pct' => $resyncPct,
|
||||
'exit_code' => $exitCode,
|
||||
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
|
||||
'errors' => $lastErrors,
|
||||
'last_date' => $lastDate,
|
||||
'last_ts' => $lastTs,
|
||||
'last_duration' => $lastDuration,
|
||||
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
|
||||
'next_ts' => $nextTs,
|
||||
];
|
||||
}
|
||||
|
||||
function vv_storage_pools(): array {
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api && isset($api['array']['caches'])) {
|
||||
$out = [];
|
||||
foreach ($api['array']['caches'] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'data');
|
||||
if ($entry) $out[] = $entry;
|
||||
}
|
||||
if (!empty($out)) {
|
||||
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('storage_pools');
|
||||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||||
$out = [];
|
||||
foreach ($ini as $key => $d) {
|
||||
if (($d['type'] ?? '') !== 'Cache') continue;
|
||||
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
|
||||
$entry = vv_disk_entry($d, $key);
|
||||
if ($entry) $out[] = $entry;
|
||||
}
|
||||
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
function vv_array_disks(): array {
|
||||
// ── API path ──────────────────────────────────────────────────────────────
|
||||
$api = vv_api_data();
|
||||
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
|
||||
$parity = [];
|
||||
$data = [];
|
||||
foreach ($api['array']['parities'] ?? [] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'parity');
|
||||
if ($entry) $parity[] = $entry;
|
||||
}
|
||||
foreach ($api['array']['disks'] ?? [] as $d) {
|
||||
$entry = vv_api_disk_entry($d, 'data');
|
||||
if ($entry) $data[] = $entry;
|
||||
}
|
||||
if (!empty($parity) || !empty($data)) {
|
||||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
return array_merge($parity, $data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local fallback ────────────────────────────────────────────────────────
|
||||
vv_api_record_fallback('array_disks');
|
||||
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
|
||||
$parity = [];
|
||||
$data = [];
|
||||
foreach ($ini as $key => $d) {
|
||||
$type = $d['type'] ?? '';
|
||||
if ($type === 'Parity') {
|
||||
$entry = vv_disk_entry($d, $key, 'parity');
|
||||
if ($entry) $parity[] = $entry;
|
||||
} elseif ($type === 'Data') {
|
||||
$entry = vv_disk_entry($d, $key, 'data');
|
||||
if ($entry) $data[] = $entry;
|
||||
}
|
||||
}
|
||||
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
|
||||
return array_merge($parity, $data);
|
||||
}
|
||||
|
||||
function vv_disk_io_rates(): array {
|
||||
$snapFile = VV_CACHE_DIR . '/vv_diskio_snap.json';
|
||||
$now = microtime(true);
|
||||
|
||||
// Read current whole-disk stats from /proc/diskstats
|
||||
$current = [];
|
||||
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||
$p = preg_split('/\s+/', trim($line));
|
||||
if (count($p) < 14) continue;
|
||||
$dev = $p[2];
|
||||
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
|
||||
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
|
||||
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
|
||||
}
|
||||
|
||||
// Load previous snapshot
|
||||
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
|
||||
$prevTime = (float)($snap['t'] ?? $now);
|
||||
$prev = $snap['d'] ?? [];
|
||||
|
||||
// Save current snapshot
|
||||
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$dt = max(0.5, $now - $prevTime);
|
||||
$out = [];
|
||||
foreach ($current as $dev => [$rs, $ws]) {
|
||||
$entry = [
|
||||
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
|
||||
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
|
||||
];
|
||||
if (isset($prev[$dev])) {
|
||||
[$prs, $pws] = $prev[$dev];
|
||||
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
|
||||
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
|
||||
if ($r > 0.01) $entry['r'] = round($r, 1);
|
||||
if ($w > 0.01) $entry['w'] = round($w, 1);
|
||||
}
|
||||
$out[$dev] = $entry;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function vv_disk_thresholds(): array {
|
||||
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
|
||||
$get = function(string $key) use ($cfg): ?int {
|
||||
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
|
||||
? (int)$m[1] : null;
|
||||
};
|
||||
return [
|
||||
'util_warn' => $get('warning') ?? 70,
|
||||
'util_crit' => $get('critical') ?? 90,
|
||||
'hdd_warn' => $get('hot') ?? 45,
|
||||
'hdd_crit' => $get('max') ?? 55,
|
||||
'ssd_warn' => $get('hotssd') ?? 60,
|
||||
'ssd_crit' => $get('maxssd') ?? 70,
|
||||
];
|
||||
}
|
||||
|
||||
// Fetch a lightweight snapshot from each remote host that has an API key configured.
|
||||
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
|
||||
function vv_remote_hosts_stats(): array {
|
||||
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
|
||||
$vars = vv_conf_vars();
|
||||
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
|
||||
$raw = file_get_contents($f) ?: '';
|
||||
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
|
||||
foreach ($m[1] as $i => $key) {
|
||||
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
|
||||
}
|
||||
}
|
||||
$myHost = vv_detect_host();
|
||||
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
|
||||
sort($hostIds);
|
||||
|
||||
$results = [];
|
||||
foreach ($hostIds as $id) {
|
||||
if (strtolower($id) === strtolower($myHost)) continue;
|
||||
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
|
||||
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
|
||||
if (file_exists($bgCache)) {
|
||||
$cached = json_decode(file_get_contents($bgCache), true);
|
||||
if ($cached) {
|
||||
$cached['cache_age'] = time() - (int)filemtime($bgCache);
|
||||
$results[$id] = $cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No background cache yet — fall back to live call (uses 30s inline cache).
|
||||
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
|
||||
if (!$key) {
|
||||
$results[$id] = ['available' => false, 'no_api_key' => true,
|
||||
'host_id' => $id, 'hostname' => $vars[$id]];
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheFile = VV_CACHE_DIR . "/vv_remote_{$id}.json";
|
||||
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
|
||||
$cached = json_decode(file_get_contents($cacheFile), true);
|
||||
if ($cached) { $results[$id] = $cached; continue; }
|
||||
}
|
||||
|
||||
$gql = '{
|
||||
info { os { hostname uptime release } cpu { brand threads cores } }
|
||||
metrics { cpu { percentTotal } memory { percentTotal total used available } }
|
||||
array {
|
||||
state
|
||||
disks { fsSize fsUsed temp }
|
||||
caches { fsSize fsUsed temp }
|
||||
parities { temp }
|
||||
}
|
||||
vms { domains { name } }
|
||||
}';
|
||||
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
|
||||
|
||||
if (!$data) {
|
||||
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
|
||||
file_put_contents($cacheFile, json_encode($entry));
|
||||
$results[$id] = $entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
$os = $data['info']['os'] ?? [];
|
||||
$cpu = $data['info']['cpu'] ?? [];
|
||||
$mMem = $data['metrics']['memory'] ?? [];
|
||||
|
||||
$memPct = round((float)($mMem['percentTotal'] ?? 0));
|
||||
if ($memPct === 0) {
|
||||
$totalBytes = (float)($mMem['total'] ?? 0);
|
||||
$availBytes = (float)($mMem['available'] ?? 0);
|
||||
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
|
||||
}
|
||||
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
|
||||
|
||||
$uptimeRaw = $os['uptime'] ?? '';
|
||||
if (is_numeric($uptimeRaw)) {
|
||||
$uptimeSec = (int)$uptimeRaw;
|
||||
$days = intdiv($uptimeSec, 86400);
|
||||
$hours = intdiv($uptimeSec % 86400, 3600);
|
||||
$mins = intdiv($uptimeSec % 3600, 60);
|
||||
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
|
||||
} else {
|
||||
$uptimeSec = 0;
|
||||
$uptime = $uptimeRaw ?: '—';
|
||||
}
|
||||
|
||||
$nodeMetrics = vv_api_node_metrics($data);
|
||||
$entry = array_merge([
|
||||
'available' => true,
|
||||
'host_id' => $id,
|
||||
'hostname' => $os['hostname'] ?? $vars[$id],
|
||||
'version' => $os['release'] ?? '',
|
||||
'uptime' => $uptime,
|
||||
'uptime_sec' => $uptimeSec,
|
||||
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
|
||||
'cpu_threads' => (int)($cpu['threads'] ?? 0),
|
||||
'mem_total_gb' => $memTotalGb,
|
||||
'mem_used_pct' => $memPct,
|
||||
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
|
||||
], $nodeMetrics);
|
||||
file_put_contents($cacheFile, json_encode($entry));
|
||||
$results[$id] = $entry;
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
function vv_log_tail(string $path, int $lines): string {
|
||||
$fp = @fopen($path, 'r');
|
||||
if (!$fp) return '';
|
||||
fseek($fp, 0, SEEK_END);
|
||||
$size = ftell($fp);
|
||||
if ($size <= 0) { fclose($fp); return ''; }
|
||||
$chunk = min($size, 4096);
|
||||
fseek($fp, -$chunk, SEEK_END);
|
||||
$data = fread($fp, $chunk);
|
||||
fclose($fp);
|
||||
$all = explode("\n", $data ?: '');
|
||||
return implode("\n", array_slice($all, -$lines));
|
||||
}
|
||||
|
||||
function vv_parse_bash_array(string $raw, string $varName): array {
|
||||
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
|
||||
$items = [];
|
||||
foreach (explode("\n", $m[1]) as $line) {
|
||||
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
|
||||
if ($line !== '') $items[] = $line;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
function vv_transcode_sessions(): array {
|
||||
$v = vv_conf_vars();
|
||||
$stateDir = rtrim($v['STATE_DIR'] ?? STATE_DIR, '/');
|
||||
$stateFile = "$stateDir/transcode_state.db";
|
||||
if (!file_exists($stateFile)) return ['available' => false];
|
||||
|
||||
$raw = [];
|
||||
foreach (file($stateFile) ?: [] as $line) {
|
||||
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
|
||||
$raw[trim($k)] = trim($v);
|
||||
}
|
||||
|
||||
$target = $raw['current_target'] ?? '';
|
||||
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
|
||||
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
|
||||
$isRamdisk = str_contains($target, 'ramdisk');
|
||||
|
||||
// Count active session dirs in both known locations
|
||||
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
|
||||
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
|
||||
|
||||
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
|
||||
$ssdPath = '';
|
||||
$ssdSessions = 0;
|
||||
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
|
||||
$parts = explode('/', rtrim($p, '/'));
|
||||
array_pop($parts);
|
||||
$mount = implode('/', $parts) ?: '/';
|
||||
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
|
||||
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
|
||||
$ssdPath = $p;
|
||||
break;
|
||||
}
|
||||
$ssd = ['available' => false];
|
||||
if ($ssdPath) {
|
||||
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
|
||||
$parts = explode('/', rtrim($ssdPath, '/'));
|
||||
array_pop($parts);
|
||||
$ssdMount = implode('/', $parts) ?: '/';
|
||||
$ssd = vv_df($ssdMount);
|
||||
}
|
||||
|
||||
// Ramdisk disk usage
|
||||
$rd = vv_df('/mnt/ramdisk_transcodes');
|
||||
|
||||
// Last cleanup values from transcode management log
|
||||
$lastRdFreed = null;
|
||||
$lastSsdFreed = null;
|
||||
$logFile = LOG_DIR . '/Orchestrators/transcode_management.log';
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach (array_reverse($lines) as $line) {
|
||||
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
|
||||
$lastRdFreed = $m[1];
|
||||
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
|
||||
$lastSsdFreed = $m[1];
|
||||
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'current_target' => $target,
|
||||
'is_ramdisk' => $isRamdisk,
|
||||
'flip_count_hour' => $flipCount,
|
||||
'last_flip_time' => $lastFlip,
|
||||
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
|
||||
'ram_sessions' => $ramSessions,
|
||||
'ssd_sessions' => $ssdSessions,
|
||||
'ramdisk' => $rd,
|
||||
'ssd' => $ssd,
|
||||
'last_rd_freed' => $lastRdFreed,
|
||||
'last_ssd_freed' => $lastSsdFreed,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$hostId = vv_detect_host();
|
||||
$hostIdUp = strtoupper($hostId);
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
|
||||
|
||||
$items = [];
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1);
|
||||
$host1 = trim($m1[1] ?? '');
|
||||
$items[] = [
|
||||
'id' => 'identity',
|
||||
'label' => 'Server identity',
|
||||
'ok' => !empty($host1),
|
||||
'detail' => $host1 ? "HOST1: $host1" : 'HOST1 blank in master.conf',
|
||||
];
|
||||
|
||||
// ── Host conf ─────────────────────────────────────────────────────────────────
|
||||
$confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf');
|
||||
$items[] = [
|
||||
'id' => 'host_conf',
|
||||
'label' => 'Host configuration',
|
||||
'ok' => $confExists,
|
||||
'detail' => $confExists
|
||||
? "$hostId.conf present"
|
||||
: ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"),
|
||||
];
|
||||
|
||||
// ── Unraid API key ─────────────────────────────────────────────────────────────
|
||||
$apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY'));
|
||||
$items[] = [
|
||||
'id' => 'api_key',
|
||||
'label' => 'Unraid API key',
|
||||
'ok' => !empty($apiKey),
|
||||
'detail' => $apiKey ? 'Key present' : 'Not set',
|
||||
'action' => $apiKey ? null : 'create_key',
|
||||
];
|
||||
|
||||
// ── SSH key ────────────────────────────────────────────────────────────────────
|
||||
$sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY'));
|
||||
$sshOk = $sshPath && file_exists($sshPath);
|
||||
$items[] = [
|
||||
'id' => 'ssh_key',
|
||||
'label' => 'SSH key',
|
||||
'ok' => $sshOk,
|
||||
'detail' => $sshOk
|
||||
? basename($sshPath)
|
||||
: ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'),
|
||||
'action' => $sshOk ? null : 'ssh_setup',
|
||||
];
|
||||
|
||||
// ── Auto-populate (any service key or container detected) ──────────────────────
|
||||
$populated = false;
|
||||
foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) {
|
||||
if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') {
|
||||
$populated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$items[] = [
|
||||
'id' => 'populated',
|
||||
'label' => 'Auto-populate',
|
||||
'ok' => $populated,
|
||||
'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet',
|
||||
'action' => $populated ? null : 'run_populate',
|
||||
];
|
||||
|
||||
// ── Partnership (only if a partner is configured) ──────────────────────────────
|
||||
preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2);
|
||||
$host2 = trim($m2[1] ?? '');
|
||||
if (!empty($host2)) {
|
||||
$state = vv_setup_state_read();
|
||||
$p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']);
|
||||
$p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']);
|
||||
$items[] = [
|
||||
'id' => 'partnership',
|
||||
'label' => 'Partnership',
|
||||
'ok' => $p1done && $p2done,
|
||||
'detail' => ($p1done && $p2done)
|
||||
? "Active with $host2"
|
||||
: ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"),
|
||||
'action' => (!$p1done) ? 'onboard' : null,
|
||||
];
|
||||
}
|
||||
|
||||
$allOk = !in_array(false, array_column($items, 'ok'), true);
|
||||
|
||||
echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/config.php';
|
||||
|
||||
$hostId = vv_detect_host();
|
||||
$hostIdUp = strtoupper($hostId);
|
||||
$master = vv_read_conf_raw('master.conf');
|
||||
$confRaw = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
|
||||
|
||||
$items = [];
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1);
|
||||
$host1 = trim($m1[1] ?? '');
|
||||
$items[] = [
|
||||
'id' => 'identity',
|
||||
'label' => 'Server identity',
|
||||
'ok' => !empty($host1),
|
||||
'detail' => $host1 ? "HOST1: $host1" : 'HOST1 blank in master.conf',
|
||||
];
|
||||
|
||||
// ── Host conf ─────────────────────────────────────────────────────────────────
|
||||
$confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf');
|
||||
$items[] = [
|
||||
'id' => 'host_conf',
|
||||
'label' => 'Host configuration',
|
||||
'ok' => $confExists,
|
||||
'detail' => $confExists
|
||||
? "$hostId.conf present"
|
||||
: ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"),
|
||||
];
|
||||
|
||||
// ── Unraid API key ─────────────────────────────────────────────────────────────
|
||||
$apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY'));
|
||||
$items[] = [
|
||||
'id' => 'api_key',
|
||||
'label' => 'Unraid API key',
|
||||
'ok' => !empty($apiKey),
|
||||
'detail' => $apiKey ? 'Key present' : 'Not set',
|
||||
'action' => $apiKey ? null : 'create_key',
|
||||
];
|
||||
|
||||
// ── SSH key ────────────────────────────────────────────────────────────────────
|
||||
$sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY'));
|
||||
$sshOk = $sshPath && file_exists($sshPath);
|
||||
$items[] = [
|
||||
'id' => 'ssh_key',
|
||||
'label' => 'SSH key',
|
||||
'ok' => $sshOk,
|
||||
'detail' => $sshOk
|
||||
? basename($sshPath)
|
||||
: ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'),
|
||||
'action' => $sshOk ? null : 'ssh_setup',
|
||||
];
|
||||
|
||||
// ── Auto-populate (any service key or container detected) ──────────────────────
|
||||
$populated = false;
|
||||
foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) {
|
||||
if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') {
|
||||
$populated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$items[] = [
|
||||
'id' => 'populated',
|
||||
'label' => 'Auto-populate',
|
||||
'ok' => $populated,
|
||||
'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet',
|
||||
'action' => $populated ? null : 'run_populate',
|
||||
];
|
||||
|
||||
// ── master.conf pull (partner servers only) ───────────────────────────────────────────────────
|
||||
if ($hostId !== 'host1' && $hostId !== 'unknown') {
|
||||
$state = vv_setup_state_read();
|
||||
$pulled = !empty($state['master_conf_pulled']);
|
||||
$items[] = [
|
||||
'id' => 'master_conf',
|
||||
'label' => 'master.conf',
|
||||
'ok' => $pulled,
|
||||
'detail' => $pulled
|
||||
? 'Synced from HOST1'
|
||||
: ($host1 ? "Not yet pulled from $host1" : 'HOST1 hostname not set in master.conf'),
|
||||
'action' => (!$pulled && $host1) ? 'pull_master' : null,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Partnership (only if a partner is configured) ──────────────────────────────
|
||||
preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2);
|
||||
$host2 = trim($m2[1] ?? '');
|
||||
if (!empty($host2)) {
|
||||
$state = vv_setup_state_read();
|
||||
$p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']);
|
||||
$p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']);
|
||||
$items[] = [
|
||||
'id' => 'partnership',
|
||||
'label' => 'Partnership',
|
||||
'ok' => $p1done && $p2done,
|
||||
'detail' => ($p1done && $p2done)
|
||||
? "Active with $host2"
|
||||
: ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"),
|
||||
'action' => (!$p1done) ? 'onboard' : null,
|
||||
];
|
||||
}
|
||||
|
||||
$allOk = !in_array(false, array_column($items, 'ok'), true);
|
||||
|
||||
echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user