Consolidate all paths to plugin flash dir, fix watchdog 7.3 triggers

- Move SCRIPTS_DIR/DATA_DIR/STATE_DIR from appdata to /boot/config/plugins/varaverk
- All state files now in STATE_DIR (no more /tmp or /boot/config root writes)
- Bootstrap: Gitea-first clone with GitHub fallback, no array dependency
- varaverk.cfg seeded with Gitea connection settings
- .gitignore: add State_Files/, varaverk.cfg, varaverk-*.txz
- Partnership/transcode/fallback scripts use STATE_DIR variables
- PHP config.php: DATA_DIR/STATE_DIR constants, VV_SETUP_STATE_FILE dynamic
- deploy.sh PROD_ROOT updated to plugin flash dir

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gmer4Lfe
2026-05-31 13:30:20 -04:00
co-authored by Claude Sonnet 4.6
parent 9191a54637
commit fb0530deba
40 changed files with 1609 additions and 262 deletions
+10
View File
@@ -7,9 +7,19 @@ Configurations/*.bak
# ── Runtime state, data, logs ───────────────────────────────────────────────── # ── Runtime state, data, logs ─────────────────────────────────────────────────
data/ data/
State_Files/
*.log *.log
*.lock *.lock
# ── Plugin config files (co-located with scripts on flash) ───────────────────
varaverk.cfg
varaverk.cron
varaverk-*.txz
# ── Build artifacts ───────────────────────────────────────────────────────────
# .txz packages are attached to GitHub releases, not committed to the repo.
Plugin/dist/
# ── OS / editor ─────────────────────────────────────────────────────────────── # ── OS / editor ───────────────────────────────────────────────────────────────
.DS_Store .DS_Store
*.swp *.swp
+33 -22
View File
@@ -111,11 +111,14 @@
# ============================================================================================== # ==============================================================================================
# ── SHARED HOST CONFIGURATION ───────────────────────────────────────────────────────────────── # ── SHARED HOST CONFIGURATION ─────────────────────────────────────────────────────────────────
# ============================================================================================== # ==============================================================================================
# DATA_DIR is the same path on all servers — persistent script state and statistics. # All Varaverk persistent data lives under /mnt/user/appdata/Varaverk/.
# Array share — survives reboots, no flash drive wear. # Both directories are created automatically if they don't exist.
# Created automatically if it doesn't exist. #
# Only truly critical files (fallback state, watchdog reboot log) stay on /boot/config. # DATA_DIR — historical logs, statistics, discovery histories, blocklists
DATA_DIR="/mnt/user/appdata/Varaverk/data" # STATE_DIR — runtime state files for all scripts (watchdogs, fallback, transcode, etc.)
# Requirement: ALL state files MUST use $STATE_DIR. No /tmp, no /boot/config.
DATA_DIR="/boot/config/plugins/varaverk/data"
STATE_DIR="/boot/config/plugins/varaverk/State_Files"
# ── Version Parity ── # ── Version Parity ──
# Controls behaviour when local and remote unRAID versions differ. # Controls behaviour when local and remote unRAID versions differ.
@@ -152,9 +155,11 @@
# All identity vars (hostnames, SSH keys) live in host*.conf. # All identity vars (hostnames, SSH keys) live in host*.conf.
# Hostnames already match Tailscale device names — IP resolution is automatic. # Hostnames already match Tailscale device names — IP resolution is automatic.
# #
# State files on /boot/config — survives reboots, available before array starts: # State files in STATE_DIR — survives reboots, array must be up (scripts run after array start):
# /boot/config/partnership_HOST1.db ← HOST1 writes only # STATE_DIR/partnership_HOST1.db ← HOST1 writes only
# /boot/config/partnership_HOST2.db ← HOST2 writes only # STATE_DIR/partnership_HOST2.db ← HOST2 writes only
# STATE_DIR/partnership_blocklist.db ← shared blocklist
# STATE_DIR/varaverk_setup.db ← onboarding progress state
# Propagated via SSH — no rsync needed # Propagated via SSH — no rsync needed
# #
# critical_sync_maintenance.sh runs --check every 30min: # critical_sync_maintenance.sh runs --check every 30min:
@@ -185,6 +190,11 @@
PARTNERSHIP_OFFLINE_THRESHOLD=30 # days either server unreachable before auto-offboard PARTNERSHIP_OFFLINE_THRESHOLD=30 # days either server unreachable before auto-offboard
# works both directions independently # works both directions independently
# Partnership and setup state files — all in STATE_DIR per the project requirement.
# Scripts use these variables; do not hardcode /boot/config paths in scripts.
PARTNERSHIP_BLOCKLIST_FILE="$STATE_DIR/partnership_blocklist.db"
VARAVERK_SETUP_FILE="$STATE_DIR/varaverk_setup.db"
# Tailscale removal on offboard. # Tailscale removal on offboard.
PARTNERSHIP_REMOVE_TAILSCALE=true # remove mirror from Tailscale tailnet on offboard PARTNERSHIP_REMOVE_TAILSCALE=true # remove mirror from Tailscale tailnet on offboard
# false = skip removal (manual or testing) # false = skip removal (manual or testing)
@@ -250,7 +260,7 @@
GITEA_CONTAINER="Gitea" GITEA_CONTAINER="Gitea"
GITEA_REPO_PATH="FailedProxy/Varaverk.git" GITEA_REPO_PATH="FailedProxy/Varaverk.git"
GITEA_DOMAIN="" # e.g. git.yourdomain.com — requires NPM + DNS GITEA_DOMAIN="" # e.g. git.yourdomain.com — requires NPM + DNS
TARGET_DIR="/mnt/user/appdata/Varaverk" TARGET_DIR="/boot/config/plugins/varaverk"
GITEA_SSH_KEY="/root/.ssh/unraid_gitea" GITEA_SSH_KEY="/root/.ssh/unraid_gitea"
SSH_PORT=221 # Gitea SSH port (default 22, Gitea often uses 221/222) SSH_PORT=221 # Gitea SSH port (default 22, Gitea often uses 221/222)
GITEA_HTTP_PORT=3000 # Gitea web/API port — used by gitea_ssh_setup.sh GITEA_HTTP_PORT=3000 # Gitea web/API port — used by gitea_ssh_setup.sh
@@ -420,7 +430,7 @@
MONTHLY_UPTIME_THRESHOLD_DAYS=30 # minimum uptime in days before maintenance fires MONTHLY_UPTIME_THRESHOLD_DAYS=30 # minimum uptime in days before maintenance fires
MONTHLY_RUN_INTERVAL_DAYS=30 # minimum days since last run before running again MONTHLY_RUN_INTERVAL_DAYS=30 # minimum days since last run before running again
MONTHLY_LAST_RUN_FILE="/boot/config/monthly_maintenance_last_run.db" MONTHLY_LAST_RUN_FILE="$STATE_DIR/monthly_maintenance_last_run.db"
# ━━━ Sunday Morning Coffee Report ━━━ # ━━━ Sunday Morning Coffee Report ━━━
# Orchestrator that runs all Sunday monitor scripts in sequence. # Orchestrator that runs all Sunday monitor scripts in sequence.
@@ -633,7 +643,7 @@
EXTERNAL_IP="8.8.8.8" EXTERNAL_IP="8.8.8.8"
FALLBACK_CHECK_INTERVAL=30 # seconds between fallback state checks FALLBACK_CHECK_INTERVAL=30 # seconds between fallback state checks
FALLBACK_HANDBACK_STRIKES=3 # consecutive healthy checks before initiating handback (3×30s = 90s) FALLBACK_HANDBACK_STRIKES=3 # consecutive healthy checks before initiating handback (3×30s = 90s)
FALLBACK_STATE_FILE="/boot/config/fallback_state.db" FALLBACK_STATE_FILE="$STATE_DIR/fallback_state.db"
FALLBACK_ENABLED=true # HOST2 back online FALLBACK_ENABLED=true # HOST2 back online
# false = suppresses "not running" warnings in status scripts # false = suppresses "not running" warnings in status scripts
FALLBACK_PARTNERSHIP_REQUIRED=true # gate fallback on an active partnership FALLBACK_PARTNERSHIP_REQUIRED=true # gate fallback on an active partnership
@@ -700,8 +710,8 @@
# HOST*_WATCHDOG_SCAN_IGNORE — skip in Tier 2 scan # HOST*_WATCHDOG_SCAN_IGNORE — skip in Tier 2 scan
# HOST*_WATCHDOG_DEPENDENCIES — dependency ordering for restart decisions # HOST*_WATCHDOG_DEPENDENCIES — dependency ordering for restart decisions
# Strike state file — /tmp resets on reboot (correct — no stale strikes after reboot) # Strike state file — persistent in STATE_DIR
WATCHDOG_STATE_FILE="/tmp/container_watchdog_state.db" WATCHDOG_STATE_FILE="$STATE_DIR/container_watchdog_state.db"
# CPU thresholds — normalised against total core count at runtime # CPU thresholds — normalised against total core count at runtime
SOFT_CPU_THRESHOLD=80 # warn at this % of total system CPU SOFT_CPU_THRESHOLD=80 # warn at this % of total system CPU
@@ -768,8 +778,8 @@
WATCHDOG_APPDATA_LOG_MAX_GB=2 # flag *.log files exceeding this size (absolute) WATCHDOG_APPDATA_LOG_MAX_GB=2 # flag *.log files exceeding this size (absolute)
WATCHDOG_APPDATA_TRUNCATE_LOGS=false # set true to auto-truncate oversized *.log files on action cycle WATCHDOG_APPDATA_TRUNCATE_LOGS=false # set true to auto-truncate oversized *.log files on action cycle
WATCHDOG_APPDATA_STRIKE_LIMIT=3 # cycles before action fires (matches existing watchdog pattern) WATCHDOG_APPDATA_STRIKE_LIMIT=3 # cycles before action fires (matches existing watchdog pattern)
WATCHDOG_APPDATA_GROWTH_FILE="/tmp/watchdog_appdata_growth.db" # /tmp — resets on reboot ✅ WATCHDOG_APPDATA_GROWTH_FILE="$STATE_DIR/watchdog_appdata_growth.db"
STORAGE_WATCHDOG_STATE_FILE="/tmp/storage_watchdog_state.db" # /tmp — resets on reboot ✅ STORAGE_WATCHDOG_STATE_FILE="$STATE_DIR/storage_watchdog_state.db"
# ━━━ Docker Network Connect ━━━ # ━━━ Docker Network Connect ━━━
# Ensures custom networks exist and connects containers at array start. # Ensures custom networks exist and connects containers at array start.
@@ -855,7 +865,7 @@
NETWORK_WATCHDOG_CHECK_TAILSCALE=true NETWORK_WATCHDOG_CHECK_TAILSCALE=true
NETWORK_WATCHDOG_NPM_TIMEOUT=10 NETWORK_WATCHDOG_NPM_TIMEOUT=10
NETWORK_WATCHDOG_NPM_STRIKE_LIMIT=2 NETWORK_WATCHDOG_NPM_STRIKE_LIMIT=2
NETWORK_WATCHDOG_NPM_STATE_FILE="/tmp/network_watchdog_state.db" NETWORK_WATCHDOG_NPM_STATE_FILE="$STATE_DIR/network_watchdog_state.db"
# ━━━ WebGUI Watchdog ━━━ # ━━━ WebGUI Watchdog ━━━
# Monitors unRAID WebGUI responsiveness — escalates through nginx restart → emhttp restart. # Monitors unRAID WebGUI responsiveness — escalates through nginx restart → emhttp restart.
@@ -1164,6 +1174,7 @@
TRANSCODE_MANAGER_MODE="smart" TRANSCODE_MANAGER_MODE="smart"
# Daily statistics log — read by weekly_health_digest.sh for transcode summary. # Daily statistics log — read by weekly_health_digest.sh for transcode summary.
TRANSCODE_STATE_FILE="$STATE_DIR/transcode_state.db"
TRANSCODE_DAILY_LOG="$DATA_DIR/transcode_daily.db" TRANSCODE_DAILY_LOG="$DATA_DIR/transcode_daily.db"
TRANSCODE_LOG_RETENTION=90 # days before old entries purged TRANSCODE_LOG_RETENTION=90 # days before old entries purged
@@ -1209,7 +1220,7 @@
# ━━━ ZFS Memory Snapshot ━━━ # ━━━ ZFS Memory Snapshot ━━━
# Weekly ZFS pool health and memory diagnostic report — informational only. # Weekly ZFS pool health and memory diagnostic report — informational only.
ZFS_REPORT_LOG="/var/log/zfs-weekly-health.log" ZFS_REPORT_LOG="$DATA_DIR/zfs-weekly-health.log"
ZFS_REPORT_ARC_WARN_PCT=90 # warn if ARC using more than this % of its max ZFS_REPORT_ARC_WARN_PCT=90 # warn if ARC using more than this % of its max
ZFS_REPORT_FREE_WARN_GB=10 # warn if less than this GB free RAM ZFS_REPORT_FREE_WARN_GB=10 # warn if less than this GB free RAM
ZFS_REPORT_AVAIL_WARN_GB=20 # warn if less than this GB available on ZFS pool ZFS_REPORT_AVAIL_WARN_GB=20 # warn if less than this GB available on ZFS pool
@@ -1264,7 +1275,7 @@
# HOST*_RW_STOP_CONTAINERS — docker stop at hard pressure (in host*.conf) # HOST*_RW_STOP_CONTAINERS — docker stop at hard pressure (in host*.conf)
RW_ENABLED=true RW_ENABLED=true
RW_STATE_FILE="/tmp/resource_watchdog_state.db" RW_STATE_FILE="$STATE_DIR/resource_watchdog_state.db"
# ━━━ Pressure Thresholds ━━━ # ━━━ Pressure Thresholds ━━━
# Graduated RAM response — resource_watchdog acts before system_watchdog reboots. # Graduated RAM response — resource_watchdog acts before system_watchdog reboots.
@@ -1334,10 +1345,10 @@
# Warn/shutdown/recover RAM tiers are handled by resource_watchdog.sh # Warn/shutdown/recover RAM tiers are handled by resource_watchdog.sh
# ━━━ State Files ━━━ # ━━━ State Files ━━━
SYS_WATCHDOG_STATE_FILE="/tmp/system_watchdog_state.db" # /tmp — resets on reboot ✅ SYS_WATCHDOG_STATE_FILE="$STATE_DIR/system_watchdog_state.db"
SYS_WATCHDOG_FAILED_FILE="/boot/config/system_watchdog_failed.db" SYS_WATCHDOG_FAILED_FILE="$STATE_DIR/system_watchdog_failed.db"
SYS_WATCHDOG_REBOOT_LOG="/boot/config/system_watchdog_reboots.db" SYS_WATCHDOG_REBOOT_LOG="$STATE_DIR/system_watchdog_reboots.db"
SYS_WATCHDOG_OOM_FILE="/tmp/system_watchdog_oom.db" # /tmp — resets on reboot ✅ SYS_WATCHDOG_OOM_FILE="$STATE_DIR/system_watchdog_oom.db"
# ━━━ Strike and Reboot Loop Settings ━━━ # ━━━ Strike and Reboot Loop Settings ━━━
# Strike system: a check must fail this many consecutive cycles before action is taken. # Strike system: a check must fail this many consecutive cycles before action is taken.
+1 -1
View File
@@ -14,7 +14,7 @@
# ============================================================================================== # ==============================================================================================
DEV_ROOT="/mnt/cloud-storage/Important Shit/Git/Development/Varaverk" DEV_ROOT="/mnt/cloud-storage/Important Shit/Git/Development/Varaverk"
PROD_ROOT="/mnt/user/appdata/Varaverk" PROD_ROOT="/boot/config/plugins/varaverk"
UPGRADE="$PROD_ROOT/Deployment/conf_upgrade.sh" UPGRADE="$PROD_ROOT/Deployment/conf_upgrade.sh"
LOG="/tmp/vv_deploy.log" LOG="/tmp/vv_deploy.log"
+1 -1
View File
@@ -345,7 +345,7 @@ state_init() {
# Returns 0 (true) if the local partnership DB reports an ACTIVE partnership. # Returns 0 (true) if the local partnership DB reports an ACTIVE partnership.
# Reads /boot/config/partnership_<hostname>.db — no subprocess, no SSH. # Reads /boot/config/partnership_<hostname>.db — no subprocess, no SSH.
check_partnership_active() { check_partnership_active() {
local state_file="/boot/config/partnership_${LOCAL_SERVER_NAME}.db" local state_file="${STATE_DIR:-/boot/config}/partnership_${LOCAL_SERVER_NAME}.db"
local state local state
state=$(grep "^state=" "$state_file" 2>/dev/null | cut -d= -f2) state=$(grep "^state=" "$state_file" 2>/dev/null | cut -d= -f2)
[[ "$state" == "ACTIVE" ]] [[ "$state" == "ACTIVE" ]]
+1 -1
View File
@@ -72,7 +72,7 @@ detect_hosts
# Defaults — overridden by master.conf values # Defaults — overridden by master.conf values
MONTHLY_UPTIME_THRESHOLD_DAYS="${MONTHLY_UPTIME_THRESHOLD_DAYS:-30}" MONTHLY_UPTIME_THRESHOLD_DAYS="${MONTHLY_UPTIME_THRESHOLD_DAYS:-30}"
MONTHLY_RUN_INTERVAL_DAYS="${MONTHLY_RUN_INTERVAL_DAYS:-30}" MONTHLY_RUN_INTERVAL_DAYS="${MONTHLY_RUN_INTERVAL_DAYS:-30}"
MONTHLY_LAST_RUN_FILE="${MONTHLY_LAST_RUN_FILE:-/boot/config/monthly_maintenance_last_run.db}" MONTHLY_LAST_RUN_FILE="${MONTHLY_LAST_RUN_FILE:-${STATE_DIR:-/tmp}/monthly_maintenance_last_run.db}"
UPTIME_THRESHOLD_SECS=$(( MONTHLY_UPTIME_THRESHOLD_DAYS * 86400 )) UPTIME_THRESHOLD_SECS=$(( MONTHLY_UPTIME_THRESHOLD_DAYS * 86400 ))
INTERVAL_SECS=$(( MONTHLY_RUN_INTERVAL_DAYS * 86400 )) INTERVAL_SECS=$(( MONTHLY_RUN_INTERVAL_DAYS * 86400 ))
+1 -1
View File
@@ -164,7 +164,7 @@ DURATION=$(( CYCLE_END - CYCLE_START ))
# ============================================================================================== # ==============================================================================================
if [[ "${WATCHDOG_ORCHESTRATOR_HEARTBEAT:-true}" == true ]]; then if [[ "${WATCHDOG_ORCHESTRATOR_HEARTBEAT:-true}" == true ]]; then
HB_SECONDS=$(( ${WATCHDOG_ORCHESTRATOR_HEARTBEAT_HOURS:-1} * 3600 )) HB_SECONDS=$(( ${WATCHDOG_ORCHESTRATOR_HEARTBEAT_HOURS:-1} * 3600 ))
HB_COUNT_FILE="/tmp/watchdog_orch_hb.count" HB_COUNT_FILE="${STATE_DIR:-/tmp}/watchdog_orch_hb.count"
HB_COUNT=$(cat "$HB_COUNT_FILE" 2>/dev/null || echo 0) HB_COUNT=$(cat "$HB_COUNT_FILE" 2>/dev/null || echo 0)
HB_COUNT=$(( HB_COUNT + 1 )) HB_COUNT=$(( HB_COUNT + 1 ))
echo "$HB_COUNT" > "$HB_COUNT_FILE" echo "$HB_COUNT" > "$HB_COUNT_FILE"
+1 -1
View File
@@ -54,7 +54,7 @@ OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" ) MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
MIRROR="${!MIRROR_ID}" MIRROR="${!MIRROR_ID}"
SSH_KEY_PUB="${SSH_KEY}.pub" SSH_KEY_PUB="${SSH_KEY}.pub"
STATE_FILE="/boot/config/varaverk_setup.db" STATE_FILE="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
AUTH_KEYS="/root/.ssh/authorized_keys" AUTH_KEYS="/root/.ssh/authorized_keys"
MIRROR_SHORT="${MIRROR%%.*}" MIRROR_SHORT="${MIRROR%%.*}"
+7 -7
View File
@@ -189,7 +189,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh" source "$SCRIPT_DIR/../load_config.sh"
SSH_TIMEOUT=15 SSH_TIMEOUT=15
BLOCKLIST_FILE="/boot/config/partnership_blocklist.db" BLOCKLIST_FILE="${PARTNERSHIP_BLOCKLIST_FILE:-${STATE_DIR:-/boot/config}/partnership_blocklist.db}"
# ── Parse mode flags before parse_args ──────────────────────────────────────────────────────── # ── Parse mode flags before parse_args ────────────────────────────────────────────────────────
MODE="" MODE=""
@@ -267,11 +267,11 @@ AM_MIRROR=false
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true [[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
# State files # State files
LOCAL_STATE_FILE="/boot/config/partnership_${LOCAL_SERVER_NAME}.db" LOCAL_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${LOCAL_SERVER_NAME}.db"
REMOTE_STATE_FILE="/boot/config/partnership_${REMOTE_SERVER_NAME}.db" REMOTE_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${REMOTE_SERVER_NAME}.db"
OWNER_STATE_FILE="/boot/config/partnership_${OWNER}.db" OWNER_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${OWNER}.db"
MIRROR_STATE_FILE="/boot/config/partnership_${MIRROR}.db" MIRROR_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${MIRROR}.db"
OFFLINE_COUNTER="/boot/config/partnership_offline_days.db" OFFLINE_COUNTER="${STATE_DIR:-/boot/config}/partnership_offline_days.db"
# ── Exit Trap — restart locally stopped containers if script crashes mid-cleanup ────────────── # ── Exit Trap — restart locally stopped containers if script crashes mid-cleanup ──────────────
# Used by folderview3_remove_partner_folder() and cleanup_partner_containers() — also shared # Used by folderview3_remove_partner_folder() and cleanup_partner_containers() — also shared
@@ -1463,7 +1463,7 @@ if (vv_write_conf_raw('master.conf', \$master)) {
fi fi
# Write HOST1_LOCAL_DONE flag to setup.db # Write HOST1_LOCAL_DONE flag to setup.db
local_state_file="/boot/config/varaverk_setup.db" local_state_file="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
if [[ "$DRY_RUN" == false ]]; then if [[ "$DRY_RUN" == false ]]; then
flag_key="${MY_ID}_LOCAL_DONE" flag_key="${MY_ID}_LOCAL_DONE"
if grep -q "^${flag_key}=" "$local_state_file" 2>/dev/null; then if grep -q "^${flag_key}=" "$local_state_file" 2>/dev/null; then
+5 -5
View File
@@ -117,11 +117,11 @@ AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true [[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true [[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
LOCAL_STATE_FILE="/boot/config/partnership_${LOCAL_SERVER_NAME}.db" LOCAL_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${LOCAL_SERVER_NAME}.db"
REMOTE_STATE_FILE="/boot/config/partnership_${REMOTE_SERVER_NAME}.db" REMOTE_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${REMOTE_SERVER_NAME}.db"
OWNER_STATE_FILE="/boot/config/partnership_${OWNER}.db" OWNER_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${OWNER}.db"
MIRROR_STATE_FILE="/boot/config/partnership_${MIRROR}.db" MIRROR_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${MIRROR}.db"
OFFLINE_COUNTER="/boot/config/partnership_offline_days.db" OFFLINE_COUNTER="${STATE_DIR:-/boot/config}/partnership_offline_days.db"
acquire_lock "strict" acquire_lock "strict"
+6 -5
View File
@@ -204,7 +204,7 @@ START=$(date +%s)
write_onboard_phase() { write_onboard_phase() {
local target_id="$1" phase="$2" local target_id="$1" phase="$2"
local key="${target_id}_PHASE${phase}_DONE" local key="${target_id}_PHASE${phase}_DONE"
local state_file="/boot/config/varaverk_setup.db" 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; } [[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
if grep -q "^${key}=" "$state_file" 2>/dev/null; then if grep -q "^${key}=" "$state_file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=true|" "$state_file" sed -i "s|^${key}=.*|${key}=true|" "$state_file"
@@ -497,7 +497,7 @@ if [[ "$AM_MIRROR" == true ]]; then
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \ OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \ -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:]') '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:-/mnt/user/appdata/Varaverk}" OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-/boot/config/plugins/varaverk}"
if [[ "$DRY_RUN" == true ]]; then if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2" warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
@@ -592,9 +592,10 @@ elif [[ "$PHASE1_ONLY" == true ]]; then
# Write key-ready flag so UI can show the manual-install state # Write key-ready flag so UI can show the manual-install state
[[ "$DRY_RUN" == false ]] && { [[ "$DRY_RUN" == false ]] && {
local kflag="${MIRROR_ID}_KEY_READY" local kflag="${MIRROR_ID}_KEY_READY"
grep -q "^${kflag}=" /boot/config/varaverk_setup.db 2>/dev/null \ local _setup_f="${VARAVERK_SETUP_FILE:-${STATE_DIR:-/boot/config}/varaverk_setup.db}"
&& sed -i "s|^${kflag}=.*|${kflag}=true|" /boot/config/varaverk_setup.db \ grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
|| echo "${kflag}=true" >> /boot/config/varaverk_setup.db && sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|| echo "${kflag}=true" >> "$_setup_f"
} }
fi fi
STEP_SSH_OK=false STEP_SSH_OK=false
+3 -3
View File
@@ -145,8 +145,8 @@ AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true [[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true [[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
LOCAL_STATE_FILE="/boot/config/partnership_${LOCAL_SERVER_NAME}.db" LOCAL_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${LOCAL_SERVER_NAME}.db"
REMOTE_STATE_FILE="/boot/config/partnership_${REMOTE_SERVER_NAME}.db" REMOTE_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${REMOTE_SERVER_NAME}.db"
acquire_lock "strict" acquire_lock "strict"
@@ -295,7 +295,7 @@ if [[ "$DRY_RUN" == false ]]; then
-o ConnectTimeout="$SSH_TIMEOUT" \ -o ConnectTimeout="$SSH_TIMEOUT" \
-o StrictHostKeyChecking=no \ -o StrictHostKeyChecking=no \
"$SCRIPTS_ROOT/Configurations/master.conf" \ "$SCRIPTS_ROOT/Configurations/master.conf" \
"root@${MIRROR_IP}:/mnt/user/appdata/Varaverk/Configurations/master.conf" 2>/dev/null && \ "root@${MIRROR_IP}:/boot/config/plugins/varaverk/Configurations/master.conf" 2>/dev/null && \
log "master.conf pushed to $NEW_OWNER" || \ log "master.conf pushed to $NEW_OWNER" || \
error "Failed to push master.conf to $NEW_OWNER — set PARTNERSHIP_OWNER_HOST=\"$NEW_OWNER_ID\" manually" error "Failed to push master.conf to $NEW_OWNER — set PARTNERSHIP_OWNER_HOST=\"$NEW_OWNER_ID\" manually"
else else
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+131
View File
@@ -0,0 +1,131 @@
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🔌 PLUGIN — Manual
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Setup procedures, deployment steps, and operational reference.
For folder overview see `README-Plugin.md`. For web app logic see the headers in `unraid/include/`.
---
## ━━━ FIRST-TIME INSTALL ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### Prerequisites
- Repo cloned onto the target Unraid server
- `/boot/config/plugins/varaverk/varaverk.plg` exists on flash (see PLG SETUP below)
### Steps
```bash
cd Plugin/
./dev_install.sh
```
This creates:
```
/usr/local/emhttp/plugins/varaverk → Plugin/unraid/ (symlink)
```
Changes to any file under `Plugin/unraid/` take effect immediately in the browser —
no restart, no reinstall.
### Verify
Navigate to the Unraid web UI. **Varaverk** should appear in the Tasks menu.
Go to **Settings → Other Settings** — a Varaverk tile should also appear there.
---
## ━━━ PLG SETUP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
The `.plg` file registers Varaverk with Unraid's plugin system. It enables the cron
mechanism (`update_cron`) and makes the plugin appear on the **Plugins** management page.
Create it once on flash — it persists across reboots:
```bash
mkdir -p /boot/config/plugins/varaverk
cat > /boot/config/plugins/varaverk.plg <<'EOF'
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN [
<!ENTITY name "varaverk">
<!ENTITY author "gmer4lfe">
<!ENTITY version "2026.05.28">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" launch="varaverk/monitor" icon="/plugins/varaverk/icons/varaverk.png">
</PLUGIN>
EOF
```
The `.plg` has no packages and no remote URLs — it is local-only and is not published
to Community Applications.
**Cron flow on every boot:**
1. `event/disks_mounted/rebuild_cron` fires
2. Copies `varaverk.plg` to `/var/log/plugins/`
3. Calls `vv_cron_rebuild()` → writes `varaverk.cron` → calls `update_cron`
4. Unraid merges `varaverk.cron` into `/etc/cron.d/root`
5. crond picks up all Varaverk jobs
---
## ━━━ SCRIPTS DIRECTORY SETTING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`SCRIPTS_DIR` is the only plugin-level setting. It tells the plugin where to find the
Configurations directory and all scripts.
**Set it via:** Settings → Other Settings → Varaverk → Scripts directory
Default: `/mnt/user/appdata/Varaverk`
The value is stored in `/boot/config/plugins/varaverk/varaverk.cfg`:
```bash
SCRIPTS_DIR="/mnt/user/appdata/Varaverk"
```
All other configuration lives in `Configurations/master.conf` and `Configurations/host*.conf`.
---
## ━━━ REPO MOVE PROCEDURE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
If the repo is cloned to a new path:
1. Re-run `dev_install.sh` — removes the stale symlink and creates a new one pointing at the new path
2. Update `SCRIPTS_DIR` in Settings → Other Settings → Varaverk (or edit `varaverk.cfg` directly on flash)
The `.plg` on flash does not need to change — it has no path references.
---
## ━━━ UPDATING THE PLUGIN VERSION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
The version in `/boot/config/plugins/varaverk.plg` is cosmetic for a local plugin —
Unraid does not check it against anything remote. Update it when you want the Plugins
management page to reflect when the plugin was last changed:
```xml
<!ENTITY version "2026.05.28">
```
---
## ━━━ ADDING A NEW OS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`dev_install.sh` is built to support multiple OS targets. To add one:
1. Create `Plugin/<os>/` with the OS-appropriate web app files
2. Add the OS marker to `detect_os()` in `dev_install.sh`:
```bash
elif [[ -f /etc/<os>-marker ]]; then echo "<os>"
```
3. Add the install target to the `case` block:
```bash
<os>)
SOURCE="$SCRIPT_DIR/<os>"
TARGET="/path/to/web/server/plugins/$PLUGIN_NAME"
;;
```
+86
View File
@@ -0,0 +1,86 @@
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🔌 PLUGIN
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**The Varaverk Unraid plugin — a web UI that wraps the entire script ecosystem.**
Scheduler, Monitor, Docker management, Partnership sync, Fallback state, and Arrs —
all surfaced inside the Unraid web interface as a first-class plugin.
> **Why this folder exists:** The scripts need a control surface. Managing a 50+ container
> homelab ecosystem from terminal windows is friction. The plugin turns configuration files
> into editable forms, cron schedules into a visual scheduler, and runtime log output into
> a live dashboard — without duplicating any of the logic that already lives in common.sh
> and the conf files.
---
## ━━━ THE PROBLEM THAT BUILT THIS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
The script ecosystem works well from the command line, but day-to-day operation is not
the command line. Checking whether the nightly sync ran, adjusting a container's watchdog
limit, confirming the partnership fallback is active — all of that requires SSH sessions,
knowing which log files to look at, and remembering which conf variable controls what.
The plugin solves the visibility problem: one URL on any browser, on any device on the
Tailscale network, shows everything running and lets you act on it. No extra tooling,
no separate monitoring stack, no third-party dashboards.
---
## ━━━ WHAT THIS FOLDER CONTAINS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
Plugin/
├── dev_install.sh # One-time developer setup: symlinks plugin into web server
├── Icons/ # Source icon assets (1024px master files)
└── unraid/ # The Unraid plugin application
├── Varaverk.page # Main plugin entry point (Tasks menu)
├── VaraverkSettings.page # Unraid Settings → Other Settings entry
├── api/ # PHP API endpoints (called by JS via fetch)
├── css/ # Plugin stylesheet
├── event/ # Unraid event hooks (boot-time cron setup, array lifecycle)
├── icons/ # Plugin icons served by emhttp
├── images/ # Plugin images
├── include/ # PHP business logic shared across pages
├── js/ # Frontend JavaScript
├── pages/ # Per-tab page includes (monitor, scheduler, docker, ...)
└── run_job.sh # Script runner invoked by the Scheduler
```
---
## ━━━ RELATIONSHIP TO THE REST OF THE REPO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**Plugin is a wrapper, never a reimplementation.** Every setting the plugin reads or writes
lives in `Configurations/master.conf` or `Configurations/host*.conf` — the same files the
shell scripts read. The plugin has no separate data store. If a conf file changes outside
the plugin (by hand, by SSH), the plugin reflects it on next load.
The one exception is `varaverk.cfg` on flash (`/boot/config/plugins/varaverk/varaverk.cfg`),
which holds a single bootstrap value: `SCRIPTS_DIR`. This is the path the plugin uses to
find the Configurations directory and all scripts. Everything else flows from there.
The plugin also taps `common.sh` indirectly — `include/config.php` mirrors
`resolve_tailscale_ip()` and `detect_host()` exactly, using the same logic as common.sh
so behaviour stays consistent without a shell dependency.
---
## ━━━ SCRIPTS IN THIS FOLDER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
| Script | Role | When It Runs |
|--------|------|--------------|
| `dev_install.sh` | Symlinks `Plugin/unraid/` into Unraid's web server | Once, manually, after cloning or moving the repo |
---
## ━━━ UNRAID INTEGRATION POINTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
| File | Where it appears in Unraid |
|------|---------------------------|
| `Varaverk.page` | Tasks menu item |
| `VaraverkSettings.page` | Settings → Other Settings tile |
| `event/disks_mounted/rebuild_cron` | Fires on every boot — copies `.plg`, rebuilds cron |
| `event/disks_mounted/array_start_jobs` | Fires when array starts |
| `event/disks_unmounting/array_stop_jobs` | Fires when array stops |
| `/boot/config/plugins/varaverk.plg` | Registers the plugin with Unraid's plugin system (lives on flash, not in repo) |
+108
View File
@@ -0,0 +1,108 @@
#!/bin/bash
# ==============================================================================================
# ============================= build.sh =======================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Packages the plugin web files (Plugin/unraid/) into a Slackware .txz, the
# format unRAID re-installs from flash on every boot. This is the RELEASE path —
# for day-to-day development use dev_install.sh (symlink, instant edits).
#
# What it produces (in Plugin/dist/):
# varaverk-<version>-noarch-1.txz the package unRAID installs to
# /usr/local/emhttp/plugins/varaverk/
# varaverk-<version>-noarch-1.txz.sha256
#
# It also rewrites the <!ENTITY version> and <!ENTITY sha256> lines in
# Plugin/varaverk.plg so the .plg always points at the package just built.
#
# WHY a .txz (not the dev symlink):
# rc.local runs `plugin install` on every .plg at boot, BEFORE the array
# mounts. A symlink into /mnt/user/appdata can't be made that early (appdata
# isn't mounted) and the disks_mounted event hooks live behind that missing
# symlink — chicken-and-egg. A .txz lives on flash, so unRAID extracts it to
# RAM before the array starts; the event hooks are then present in time to
# rebuild cron when the array mounts. Scripts stay on appdata (git clone).
#
# ==============================================================================================
# USAGE
# ==============================================================================================
# ./build.sh # version = today's date (YYYY.MM.DD)
# ./build.sh 2026.09.01 # explicit version
#
# After building: commit Plugin/dist/<txz> + the updated .plg, then attach the
# .txz to a GitHub release tagged <version> so the .plg URL resolves for
# downloaders. (The .plg also works offline if the .txz is already cached on
# flash with a matching SHA256.)
# ==============================================================================================
set -euo pipefail
PLUGIN_NAME="varaverk"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="$SCRIPT_DIR/unraid"
DIST="$SCRIPT_DIR/dist"
PLG="$SCRIPT_DIR/varaverk.plg"
VERSION="${1:-$(date +%Y.%m.%d)}"
PKG_BASENAME="${PLUGIN_NAME}-${VERSION}-noarch-1"
OUTFILE="$DIST/${PKG_BASENAME}.txz"
# ── Guards ──────────────────────────────────────────────────────────────────
[[ -d "$SRC" ]] || { echo "ERROR: source not found: $SRC"; exit 1; }
command -v makepkg >/dev/null || { echo "ERROR: makepkg not found (run on unRAID)"; exit 1; }
mkdir -p "$DIST"
# ── Stage files under the real install path ─────────────────────────────────
# installpkg extracts relative to / so the package must contain the full path.
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
INSTALL_ROOT="$STAGE/usr/local/emhttp/plugins/$PLUGIN_NAME"
mkdir -p "$INSTALL_ROOT"
# Copy web files; drop VCS/editor cruft and any dev-only leftovers.
cp -a "$SRC/." "$INSTALL_ROOT/"
find "$INSTALL_ROOT" -name '.git*' -prune -exec rm -rf {} + 2>/dev/null || true
find "$INSTALL_ROOT" -name '*.swp' -delete 2>/dev/null || true
# Normalise ownership/permissions inside the package.
chown -R root:root "$STAGE" 2>/dev/null || true
find "$INSTALL_ROOT" -type d -exec chmod 0755 {} +
find "$INSTALL_ROOT" -type f -exec chmod 0644 {} +
# Keep shell/event scripts executable.
find "$INSTALL_ROOT" \( -name '*.sh' -o -path '*/event/*' \) -type f -exec chmod 0755 {} +
# ── Build the package ───────────────────────────────────────────────────────
# makepkg doesn't quote its output arg internally, so build to a space-free temp
# path (the repo lives under a dir with spaces) then move into dist/.
TMP_OUT="$STAGE.txz"
( cd "$STAGE" && makepkg -l y -c y "$TMP_OUT" >/dev/null )
mv "$TMP_OUT" "$OUTFILE"
# ── Hash + record ───────────────────────────────────────────────────────────
SHA256="$(sha256sum "$OUTFILE" | awk '{print $1}')"
echo "$SHA256 ${PKG_BASENAME}.txz" > "$OUTFILE.sha256"
# ── Point the .plg at this build ────────────────────────────────────────────
if [[ -f "$PLG" ]]; then
sed -i -E "s|(<!ENTITY[[:space:]]+version[[:space:]]+\")[^\"]*(\">)|\1${VERSION}\2|" "$PLG"
sed -i -E "s|(<!ENTITY[[:space:]]+sha256[[:space:]]+\")[^\"]*(\">)|\1${SHA256}\2|" "$PLG"
fi
SIZE="$(numfmt --to=iec "$(stat -c %s "$OUTFILE")")"
cat <<EOF
Built: $OUTFILE ($SIZE)
Version: $VERSION
SHA256: $SHA256
.plg updated (version + sha256).
Next:
1) git add Plugin/dist/${PKG_BASENAME}.txz* Plugin/varaverk.plg && commit
2) Create GitHub release tagged "$VERSION", attach ${PKG_BASENAME}.txz
(skip for a local/offline install — copy the .txz to
/boot/config/plugins/$PLUGIN_NAME/ and the .plg uses the cached copy)
EOF
+118
View File
@@ -0,0 +1,118 @@
<?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) {
$types = ['InfoOs','InfoCpu','InfoMemory','ArrayDisk','ArrayParity','ArrayCache','Vm','Domain','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);
+9 -1
View File
@@ -36,7 +36,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
$results = vv_conf_write_changes($changes); $results = vv_conf_write_changes($changes);
echo json_encode(['ok' => !in_array(false, $results, true), 'files' => $results]);
// Propagate master.conf to partner hosts when the owner edits it (mirrors rawconf.php).
$push = [];
if (($results['master.conf'] ?? false) === true) {
$push = vv_push_master_conf();
vv_push_setup_state();
}
echo json_encode(['ok' => !in_array(false, $results, true), 'files' => $results, 'push' => $push]);
exit; exit;
} }
+84
View File
@@ -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,
]);
+9 -1
View File
@@ -11,4 +11,12 @@ if (!$name || !preg_match('/^[A-Z_]+_RSYNC_ENABLED$/', $name)) {
} }
$ok = vv_conf_flag_set($name, $enabled); $ok = vv_conf_flag_set($name, $enabled);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write master.conf']);
// master.conf is shared — propagate the change to partner hosts (no-op on non-owner).
$push = [];
if ($ok) {
$push = vv_push_master_conf();
vv_push_setup_state();
}
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write master.conf', 'push' => $push]);
+14
View File
@@ -0,0 +1,14 @@
<?php
// Connectivity test — SSH echo to a partner host with round-trip latency.
// GET ?id=HOST2 (GET avoids the bodyless-POST issue on this nginx setup).
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/partnership.php';
$id = trim($_GET['id'] ?? '');
if (!preg_match('/^host\d+$/i', $id)) {
echo json_encode(['ok' => false, 'error' => 'Invalid host id']);
exit;
}
echo json_encode(vv_pt_ping($id));
@@ -0,0 +1,22 @@
<?php
// Partnership-related settings for this host — the Partnership section of each accessible conf.
// HOST1 (owner): master.conf PARTNERSHIP + host1.conf Partnership. Other hosts: their own.
// Uses vv_conf_all_groups() (handles master.conf's sandwiched major header) then filters
// to partnership sections. Writes go through confform.php (which pushes master.conf to partners).
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/confform.php';
$files = vv_get_conf_files();
$out = [];
foreach ($files as $f) {
$groups = array_values(array_filter(
vv_conf_all_groups($f),
fn($g) => stripos($g['subsection'], 'partnership') !== false
));
if ($groups) {
$out[] = ['file' => $f, 'groups' => $groups];
}
}
echo json_encode(['ok' => true, 'files' => $out]);
+1 -1
View File
@@ -50,7 +50,7 @@ if ($action === 'pull') {
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip; . ' -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"') ?: ''); $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); preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
$remoteConf = rtrim($sm[1] ?? '/mnt/user/appdata/Varaverk', '/') . '/Configurations'; $remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
// SCP master.conf from HOST1 // SCP master.conf from HOST1
$localMaster = CONF_DIR . '/master.conf'; $localMaster = CONF_DIR . '/master.conf';
+22 -16
View File
@@ -584,7 +584,17 @@ function vv_remote_hosts_stats(): array {
if ($cached) { $results[$id] = $cached; continue; } if ($cached) { $results[$id] = $cached; continue; }
} }
$gql = '{ info { os { hostname uptime release } cpu { brand threads cores } } metrics { cpu { percentTotal } memory { percentTotal total available } } array { state } }'; $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); $data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
if (!$data) { if (!$data) {
@@ -594,22 +604,17 @@ function vv_remote_hosts_stats(): array {
continue; continue;
} }
$os = $data['info']['os'] ?? []; $os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? []; $cpu = $data['info']['cpu'] ?? [];
$metrics = $data['metrics'] ?? []; $mMem = $data['metrics']['memory'] ?? [];
$mCpu = $metrics['cpu'] ?? [];
$mMem = $metrics['memory'] ?? [];
$arr = $data['array'] ?? [];
$cpuLoad = round((float)($mCpu['percentTotal'] ?? 0), 1); $memPct = round((float)($mMem['percentTotal'] ?? 0));
$memPct = round((float)($mMem['percentTotal'] ?? 0));
// Also compute from raw bytes as cross-check when percentTotal is missing
if ($memPct === 0) { if ($memPct === 0) {
$totalBytes = (float)($mMem['total'] ?? 0); $totalBytes = (float)($mMem['total'] ?? 0);
$availBytes = (float)($mMem['available'] ?? 0); $availBytes = (float)($mMem['available'] ?? 0);
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0; $memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
} }
$memTotalGb = isset($mMem['total']) ? round((float)$mMem['total'] / (1024 ** 3), 1) : 0; $memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
$uptimeRaw = $os['uptime'] ?? ''; $uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) { if (is_numeric($uptimeRaw)) {
@@ -623,19 +628,20 @@ function vv_remote_hosts_stats(): array {
$uptime = $uptimeRaw ?: '—'; $uptime = $uptimeRaw ?: '—';
} }
$entry = [ $nodeMetrics = vv_api_node_metrics($data);
$entry = array_merge([
'available' => true, 'available' => true,
'host_id' => $id, 'host_id' => $id,
'hostname' => $os['hostname'] ?? $vars[$id], 'hostname' => $os['hostname'] ?? $vars[$id],
'version' => $os['release'] ?? '', 'version' => $os['release'] ?? '',
'uptime' => $uptime, 'uptime' => $uptime,
'uptime_sec' => $uptimeSec, 'uptime_sec' => $uptimeSec,
'cpu_load' => $cpuLoad, 'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
'cpu_threads' => (int)($cpu['threads'] ?? 0), 'cpu_threads' => (int)($cpu['threads'] ?? 0),
'mem_total_gb' => $memTotalGb, 'mem_total_gb' => $memTotalGb,
'mem_used_pct' => $memPct, 'mem_used_pct' => $memPct,
'array_state' => $arr['state'] ?? 'UNKNOWN', 'array_state' => $data['array']['state'] ?? 'UNKNOWN',
]; ], $nodeMetrics);
file_put_contents($cacheFile, json_encode($entry)); file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry; $results[$id] = $entry;
} }
+36 -1
View File
@@ -67,6 +67,12 @@ function vv_conf_parse_subsection(string $raw, string $subName, string $filename
if (preg_match('/^#\s*[━─═=]{3,}/', $lines[$i])) { $end = $i; break; } if (preg_match('/^#\s*[━─═=]{3,}/', $lines[$i])) { $end = $i; break; }
} }
return _vv_conf_parse_field_range($lines, $start, $end, $filename) ?: null;
}
// Parse all config fields between two line indices. Shared by vv_conf_parse_subsection()
// (per-script editor) and vv_conf_all_groups() (full settings view).
function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $filename): array {
$fields = []; $fields = [];
$pendingDesc = []; $pendingDesc = [];
@@ -138,7 +144,36 @@ function vv_conf_parse_subsection(string $raw, string $subName, string $filename
} }
} }
return $fields ?: null; return $fields;
}
// Return ALL config groups (every named section + its fields) for a conf file.
// Enumerates header lines (# ━━━ Name ━━━ or # ── Name ──); each group runs from its
// header to the next named header so major sections (sandwiched in ===) capture their
// settings too. Empty groups (divider-only headers) are dropped.
function vv_conf_all_groups(string $filename): array {
$raw = vv_read_conf_raw($filename);
if ($raw === '') return [];
$lines = explode("\n", $raw);
$n = count($lines);
$headers = [];
for ($i = 0; $i < $n; $i++) {
if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/', $lines[$i], $m)) {
$headers[] = ['name' => trim(preg_replace('/\s+/', ' ', $m[1])), 'line' => $i];
}
}
$groups = [];
foreach ($headers as $idx => $h) {
$start = $h['line'] + 1;
$end = $headers[$idx + 1]['line'] ?? $n;
$fields = _vv_conf_parse_field_range($lines, $start, $end, $filename);
if ($fields) {
$groups[] = ['subsection' => $h['name'], 'file' => $filename, 'fields' => $fields];
}
}
return $groups;
} }
// Return all conf groups (subsection + fields) for a script on the current host. // Return all conf groups (subsection + fields) for a script on the current host.
+4 -2
View File
@@ -5,12 +5,14 @@
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg'); define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg');
$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: []; $_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/mnt/user/appdata/Varaverk'); define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
define('CONF_DIR', SCRIPTS_DIR . '/Configurations'); 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('LOG_DIR', '/var/log/varaverk');
unset($_vv_cfg); unset($_vv_cfg);
const VV_SETUP_STATE_FILE = '/boot/config/varaverk_setup.db'; define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
// Read the setup state file into a key=>value array. // Read the setup state file into a key=>value array.
function vv_setup_state_read(): array { function vv_setup_state_read(): array {
+2 -1
View File
@@ -156,7 +156,7 @@ function vv_watchdog_summary(): array {
} }
// Recent restarts (24 h) // Recent restarts (24 h)
$restartLog = '/mnt/user/appdata/Varaverk/data/container_restart_history.db'; $restartLog = DATA_DIR . '/container_restart_history.db';
$restartRaw = @file_get_contents($restartLog) ?: ''; $restartRaw = @file_get_contents($restartLog) ?: '';
$cutoff = time() - 86400; $cutoff = time() - 86400;
$restarts = []; $restarts = [];
@@ -297,6 +297,7 @@ function vv_scripts_status(): array {
$ts = (int)($stat['end'] ?? $stat['start'] ?? @filemtime($statFile) ?: 0); $ts = (int)($stat['end'] ?? $stat['start'] ?? @filemtime($statFile) ?: 0);
$scripts[] = [ $scripts[] = [
'id' => $id,
'name' => $name, 'name' => $name,
'last_ts' => $ts, 'last_ts' => $ts,
'status' => $status, 'status' => $status,
+162 -28
View File
@@ -3,23 +3,63 @@
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
require_once __DIR__ . '/arrs.php'; // vv_arr_known_hosts(), vv_arr_scalar() require_once __DIR__ . '/arrs.php'; // vv_arr_known_hosts(), vv_arr_scalar()
require_once __DIR__ . '/common.php'; // vv_system_info(), vv_docker_containers(), vv_remote_hosts_stats(), vv_api_node_metrics()
// ── Config ──────────────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────────────
function vv_pt_config(): array { function vv_pt_config(): array {
$v = vv_conf_vars(); $v = vv_conf_vars();
$offlineDays = null;
$odFile = '/boot/config/partnership_offline_days.db';
if (file_exists($odFile)) {
$raw = trim(@file_get_contents($odFile) ?: '');
if (is_numeric($raw)) $offlineDays = (int)$raw;
}
return [ return [
'enabled' => ($v['PARTNERSHIP_ENABLED'] ?? 'false') === 'true', 'enabled' => ($v['PARTNERSHIP_ENABLED'] ?? 'false') === 'true',
'owner_host' => $v['PARTNERSHIP_OWNER_HOST'] ?? '', 'owner_host' => $v['PARTNERSHIP_OWNER_HOST'] ?? '',
'sync_min' => (int)($v['PARTNERSHIP_SYNC_INTERVAL'] ?? 15), 'sync_min' => (int)($v['PARTNERSHIP_SYNC_INTERVAL'] ?? 15),
'grace_hours' => (int)($v['PARTNERSHIP_GRACE_HOURS'] ?? 6), 'grace_hours' => (int)($v['PARTNERSHIP_GRACE_HOURS'] ?? 6),
'offline_threshold' => (int)($v['PARTNERSHIP_OFFLINE_THRESHOLD'] ?? 30), 'offline_threshold' => (int)($v['PARTNERSHIP_OFFLINE_THRESHOLD'] ?? 30),
'offline_days' => $offlineDays,
'remove_tailscale' => ($v['PARTNERSHIP_REMOVE_TAILSCALE'] ?? 'true') === 'true', 'remove_tailscale' => ($v['PARTNERSHIP_REMOVE_TAILSCALE'] ?? 'true') === 'true',
'folderview3' => ($v['PARTNERSHIP_FOLDERVIEW3'] ?? 'false') === 'true', 'folderview3' => ($v['PARTNERSHIP_FOLDERVIEW3'] ?? 'false') === 'true',
'tailscale_configured' => !empty($v['TAILSCALE_API_KEY']) && !empty($v['TAILSCALE_TAILNET']), 'tailscale_configured' => !empty($v['TAILSCALE_API_KEY']) && !empty($v['TAILSCALE_TAILNET']),
'transfer_confirm' => $v['PARTNERSHIP_TRANSFER_CONFIRM'] ?? 'i-understand-this-transfers-ownership',
]; ];
} }
// ── Mirror sync health — the partnership's actual job (rsync orchestrators) ──────
function vv_pt_sync(): array {
$jobs = [
'critical' => 'Orchestrators/critical_sync_maintenance',
'daily' => 'Orchestrators/daily_sync_maintenance',
'weekly' => 'Orchestrators/weekly_sync_maintenance',
];
$out = ['jobs' => []];
foreach ($jobs as $key => $base) {
$statFile = LOG_DIR . '/' . $base . '.json';
$s = file_exists($statFile) ? json_decode(@file_get_contents($statFile), true) : null;
$out['jobs'][$key] = is_array($s) ? [
'status' => $s['status'] ?? 'unknown',
'start' => isset($s['start']) ? (int)$s['start'] : null,
'end' => isset($s['end']) ? (int)$s['end'] : null,
] : null;
}
$v = vv_conf_vars();
// Rsync gate flags (master.conf) — global Tier 1 + per-tier Tier 2.
$out['gates'] = [
'global' => ['var' => 'RSYNC_ENABLED', 'on' => ($v['RSYNC_ENABLED'] ?? 'true') === 'true'],
'critical' => ['var' => 'CRITICAL_RSYNC_ENABLED', 'on' => ($v['CRITICAL_RSYNC_ENABLED'] ?? 'true') === 'true'],
'daily' => ['var' => 'DAILY_RSYNC_ENABLED', 'on' => ($v['DAILY_RSYNC_ENABLED'] ?? 'true') === 'true'],
'weekly' => ['var' => 'WEEKLY_RSYNC_ENABLED', 'on' => ($v['WEEKLY_RSYNC_ENABLED'] ?? 'true') === 'true'],
];
// Back-compat keys still used by the warning line.
$out['rsync_enabled'] = $out['gates']['global']['on'];
$out['critical_enabled'] = $out['gates']['critical']['on'];
return $out;
}
// ── State file parser ───────────────────────────────────────────────────────── // ── State file parser ─────────────────────────────────────────────────────────
function vv_pt_read_db(string $path): array { function vv_pt_read_db(string $path): array {
@@ -76,28 +116,44 @@ function vv_pt_ssh(string $ip, string $sshKey, string $cmd, int $timeout = 4): s
// ── System info ─────────────────────────────────────────────────────────────── // ── System info ───────────────────────────────────────────────────────────────
// vv_system_info() (common.php) provides version, load_avg, array_state.
// /proc/uptime is the reliable uptime source (API uptime is an ISO date string, not seconds).
// vv_docker_containers() (common.php) provides the running container list.
function vv_pt_local_system(): array { function vv_pt_local_system(): array {
$ver = ''; $info = vv_system_info();
if (file_exists('/etc/unraid-version')) { $uptimeSec = file_exists('/proc/uptime')
preg_match('/VERSION="([^"]+)"/', file_get_contents('/etc/unraid-version'), $m); ? (int)explode(' ', file_get_contents('/proc/uptime'))[0] : 0;
$ver = $m[1] ?? ''; return [
} 'unraid_version' => $info['version'] ?? '',
$uptime = 0; 'uptime_sec' => $uptimeSec,
if (file_exists('/proc/uptime')) { 'load_avg' => isset($info['load_avg']) ? $info['load_avg'][0] : null,
$uptime = (int)explode(' ', file_get_contents('/proc/uptime'))[0]; 'containers' => count(vv_docker_containers()),
} ];
return ['unraid_version' => $ver, 'uptime_sec' => $uptime];
} }
// SSH fallback for remote nodes — version/uptime/load/containers in one call.
// API stats from vv_remote_hosts_stats() take priority when available; SSH fills gaps.
function vv_pt_remote_system(string $ip, string $sshKey): array { function vv_pt_remote_system(string $ip, string $sshKey): array {
$out = vv_pt_ssh($ip, $sshKey, $out = vv_pt_ssh($ip, $sshKey,
'printf "%s\nUPTIME:%s\n" "$(cat /etc/unraid-version 2>/dev/null)" "$(cat /proc/uptime 2>/dev/null)"'); 'printf "%s\nUPTIME:%s\nLOAD:%s\nCONTAINERS:%s\n" ' .
'"$(cat /etc/unraid-version 2>/dev/null)" ' .
'"$(cat /proc/uptime 2>/dev/null)" ' .
'"$(awk \'{print $1}\' /proc/loadavg 2>/dev/null)" ' .
'"$(docker ps -q 2>/dev/null | wc -l)"');
$ver = ''; $ver = '';
preg_match('/VERSION="([^"]+)"/', $out, $m); preg_match('/VERSION="([^"]+)"/', $out, $m); if ($m) $ver = $m[1];
if ($m) $ver = $m[1];
$uptime = 0; $uptime = 0;
if (preg_match('/UPTIME:([\d.]+)/', $out, $m)) $uptime = (int)$m[1]; if (preg_match('/UPTIME:([\d.]+)/', $out, $m)) $uptime = (int)$m[1];
return ['unraid_version' => $ver, 'uptime_sec' => $uptime]; $load = null;
if (preg_match('/LOAD:([\d.]+)/', $out, $m)) $load = round((float)$m[1], 2);
$containers = null;
if (preg_match('/CONTAINERS:(\d+)/', $out, $m)) $containers = (int)$m[1];
return [
'unraid_version' => $ver,
'uptime_sec' => $uptime,
'load_avg' => $load,
'containers' => $containers,
];
} }
// ── Per-node data ───────────────────────────────────────────────────────────── // ── Per-node data ─────────────────────────────────────────────────────────────
@@ -110,6 +166,9 @@ function vv_pt_nodes(): array {
$ownerSlot = strtolower($vars['PARTNERSHIP_OWNER_HOST'] ?? ''); $ownerSlot = strtolower($vars['PARTNERSHIP_OWNER_HOST'] ?? '');
$setupDb = vv_setup_state_read(); $setupDb = vv_setup_state_read();
// Remote host stats (API + 30s /tmp cache) — includes version, uptime, cpu/ram/array/temp/vms
$remoteStats = vv_remote_hosts_stats();
// SSH key for this host // SSH key for this host
$myId = strtoupper($currentHost); $myId = strtoupper($currentHost);
$myRaw = vv_read_conf_raw($currentHost . '.conf'); $myRaw = vv_read_conf_raw($currentHost . '.conf');
@@ -161,32 +220,107 @@ function vv_pt_nodes(): array {
// For self: local setup complete flag (set by partnership_manager --onboard --local-only) // For self: local setup complete flag (set by partnership_manager --onboard --local-only)
$localDone = $isMe && ($setupDb[$nodeIdUpper . '_LOCAL_DONE'] ?? '') === 'true'; $localDone = $isMe && ($setupDb[$nodeIdUpper . '_LOCAL_DONE'] ?? '') === 'true';
// Unraid API key status — checks Unraid's key store directly so deletions are reflected.
$apiKeySet = false;
$apiKeyPreview = '';
if ($isMe) {
$apiOut = shell_exec('/usr/local/sbin/unraid-api apikey --name "Varaverk" --json </dev/null 2>/dev/null');
$apiData = json_decode(trim($apiOut ?? ''), true);
if (is_array($apiData) && !empty($apiData['key'])) {
$apiKeySet = true;
$apiKeyPreview = substr($apiData['key'], 0, 8) . '...' . substr($apiData['key'], -4);
}
}
// Live metrics: local uses vv_api_data() (cached); remote uses vv_remote_hosts_stats() (30s cache)
if ($isMe) {
$metrics = array_merge(
vv_api_node_metrics(vv_api_data()),
array_filter([
'load_avg' => $system['load_avg'] ?? null,
'containers' => $system['containers'] ?? null,
], fn($v) => $v !== null)
);
} else {
$rStat = $remoteStats[$nodeIdUpper] ?? [];
// Merge API metrics from remote stats with SSH extras (load, containers)
$metrics = array_filter([
'cpu_pct' => $rStat['cpu_pct'] ?? null,
'ram_used_gb' => $rStat['ram_used_gb'] ?? null,
'ram_total_gb' => $rStat['ram_total_gb'] ?? null,
'array_used_tb' => $rStat['array_used_tb'] ?? null,
'array_total_tb' => $rStat['array_total_tb'] ?? null,
'max_disk_temp' => $rStat['max_disk_temp'] ?? null,
'vm_count' => $rStat['vm_count'] ?? null,
'load_avg' => $system['load_avg'] ?? null,
'containers' => $system['containers'] ?? null,
], fn($v) => $v !== null);
// Fill version/uptime from API stats if SSH didn't provide them
if (empty($system['unraid_version']) && !empty($rStat['version'])) {
$system['unraid_version'] = $rStat['version'];
}
if (empty($system['uptime_sec']) && !empty($rStat['uptime_sec'])) {
$system['uptime_sec'] = $rStat['uptime_sec'];
}
}
$nodes[] = [ $nodes[] = [
'slot' => $slot, 'slot' => $slot,
'id' => $nodeIdUpper, 'id' => $nodeIdUpper,
'hostname' => $hostname, 'hostname' => $hostname,
'is_me' => $isMe, 'is_me' => $isMe,
'is_owner' => $isOwner, 'is_owner' => $isOwner,
'ts_online' => $ts['online'], 'ts_online' => $ts['online'],
'ts_active' => $ts['active'], 'ts_active' => $ts['active'],
'ts_ip' => $ts['ip'], 'ts_ip' => $ts['ip'],
'fallback' => $fbState, 'fallback' => $fbState,
'partnership' => $ptDb, 'partnership' => $ptDb,
'system' => $system, 'system' => $system,
'onboard_phase' => $onboardPhase, 'onboard_phase' => $onboardPhase,
'key_ready' => $keyReady, 'key_ready' => $keyReady,
'local_done' => $localDone, 'local_done' => $localDone,
'api_key_set' => $apiKeySet,
'api_key_preview' => $apiKeyPreview,
'metrics' => $metrics,
]; ];
} }
return $nodes; return $nodes;
} }
// ── Connectivity test — SSH echo with round-trip timing ─────────────────────────
function vv_pt_ping(string $slot): array {
$slot = strtolower($slot);
$vars = vv_conf_vars();
$hostname = $vars[strtoupper($slot)] ?? '';
if (!$hostname) return ['ok' => false, 'error' => 'Unknown host slot'];
$currentHost = vv_detect_host();
$myRaw = vv_read_conf_raw($currentHost . '.conf');
$sshKey = vv_arr_scalar($myRaw, strtoupper($currentHost) . '_SSH_KEY');
if (!$sshKey || !file_exists($sshKey)) {
return ['ok' => false, 'error' => 'No SSH key configured on this host'];
}
$ip = vv_resolve_tailscale_ip($hostname);
if (!$ip) return ['ok' => false, 'error' => "Cannot resolve Tailscale IP for $hostname"];
$t0 = microtime(true);
$out = vv_pt_ssh($ip, $sshKey, 'echo ok', 8);
$ms = (int)round((microtime(true) - $t0) * 1000);
if (trim($out) === 'ok') {
return ['ok' => true, 'latency_ms' => $ms, 'host' => $hostname, 'ip' => $ip];
}
return ['ok' => false, 'error' => "SSH to $hostname ($ip) failed or timed out", 'host' => $hostname];
}
// ── Entry point ─────────────────────────────────────────────────────────────── // ── Entry point ───────────────────────────────────────────────────────────────
function vv_partnership_all(): array { function vv_partnership_all(): array {
return [ return [
'config' => vv_pt_config(), 'config' => vv_pt_config(),
'nodes' => vv_pt_nodes(), 'nodes' => vv_pt_nodes(),
'sync' => vv_pt_sync(),
'ts' => time(), 'ts' => time(),
]; ];
} }
+38
View File
@@ -142,6 +142,44 @@ function vv_api_disk_entry(array $d, string $role = ''): ?array {
]; ];
} }
// ── Node metrics extractor ────────────────────────────────────────────────────
// Parse CPU%, RAM, array storage, disk temps, and VM count from a raw API response.
// Used by vv_remote_hosts_stats() and the local vv_api_data() path — one parser, no duplication.
// GQL must include: metrics.cpu.percentTotal, metrics.memory.{total,used},
// array.{disks,caches,parities}.{fsSize,fsUsed,temp}, vms.domains.
function vv_api_node_metrics(?array $d): array {
if (!$d) return [];
$cpu = (int)round((float)($d['metrics']['cpu']['percentTotal'] ?? 0));
$mem = $d['metrics']['memory'] ?? [];
$ramUsed = isset($mem['used']) ? _vv_api_bytes_to_gb((float)$mem['used']) : null;
$ramTot = isset($mem['total']) ? _vv_api_bytes_to_gb((float)$mem['total']) : null;
$disks = $d['array']['disks'] ?? [];
$caches = $d['array']['caches'] ?? [];
$pars = $d['array']['parities'] ?? [];
$usedGb = 0.0; $totGb = 0.0;
foreach (array_merge($disks, $caches) as $dk) {
$sz = (float)($dk['fsSize'] ?? 0);
if ($sz <= 0) continue;
$totGb += _vv_api_bytes_to_gb($sz);
$usedGb += _vv_api_bytes_to_gb((float)($dk['fsUsed'] ?? 0));
}
$temps = array_filter(
array_merge(array_column($disks,'temp'), array_column($caches,'temp'), array_column($pars,'temp')),
fn($t) => is_numeric($t) && $t > 0
);
return [
'cpu_pct' => $cpu,
'ram_used_gb' => $ramUsed !== null ? round($ramUsed, 1) : null,
'ram_total_gb' => $ramTot !== null ? round($ramTot, 1) : null,
'array_used_tb' => $totGb > 0 ? round($usedGb / 1000, 1) : null,
'array_total_tb' => $totGb > 0 ? round($totGb / 1000, 1) : null,
'max_disk_temp' => $temps ? (int)max($temps) : null,
'vm_count' => count($d['vms']['domains'] ?? []),
];
}
// ── Confirmed schema (Unraid 7.2.5, introspected 2026-05-29) ───────────────── // ── Confirmed schema (Unraid 7.2.5, introspected 2026-05-29) ─────────────────
// Adding a new host: add HOSTn="hostname" to master.conf and HOSTn_UNRAID_API_KEY // Adding a new host: add HOSTn="hostname" to master.conf and HOSTn_UNRAID_API_KEY
// to hostn.conf, then run Deployment/deploy.sh. No schema work needed. // to hostn.conf, then run Deployment/deploy.sh. No schema work needed.
+1 -1
View File
@@ -313,7 +313,7 @@ function vv_wd_all(): array {
$currentHost = vv_detect_host(); $currentHost = vv_detect_host();
$tsPeers = vv_pt_ts_peers(); $tsPeers = vv_pt_ts_peers();
$masterRaw = vv_read_conf_raw('master.conf'); $masterRaw = vv_read_conf_raw('master.conf');
$restartLog = '/mnt/user/appdata/Varaverk/data/container_restart_history.db'; $restartLog = DATA_DIR . '/container_restart_history.db';
// Config thresholds from master.conf // Config thresholds from master.conf
$cfg = [ $cfg = [
+55 -9
View File
@@ -467,7 +467,10 @@ function vvRenderScripts() {
const color = s.status === 'running' ? '#4fc3f7' : s.status === 'ok' ? '#4caf50' : s.status === 'warn' ? '#ff9800' : s.status === 'error' ? '#f44336' : '#555'; const color = s.status === 'running' ? '#4fc3f7' : s.status === 'ok' ? '#4caf50' : s.status === 'warn' ? '#ff9800' : s.status === 'error' ? '#f44336' : '#555';
const name = s.name.length > 24 ? s.name.slice(0, 23) + '…' : s.name; const name = s.name.length > 24 ? s.name.slice(0, 23) + '…' : s.name;
const dur = s.duration != null ? ` ${s.duration}s` : ''; const dur = s.duration != null ? ` ${s.duration}s` : '';
listHtml += `<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;font-size:11px;"> const sid = (s.id || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'");
listHtml += `<div onclick="vvOpenScript('${sid}')" title="Open ${name} in Scheduler"
style="display:flex;align-items:center;gap:5px;margin-bottom:4px;font-size:11px;cursor:pointer;"
onmouseover="this.style.background='#161616'" onmouseout="this.style.background=''">
<span style="color:${color};flex-shrink:0;width:10px;text-align:center;">${icon}</span> <span style="color:${color};flex-shrink:0;width:10px;text-align:center;">${icon}</span>
<span style="color:#888;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}</span> <span style="color:#888;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}</span>
<span style="color:#444;font-size:10px;flex-shrink:0;">${ago}${dur}</span> <span style="color:#444;font-size:10px;flex-shrink:0;">${ago}${dur}</span>
@@ -485,6 +488,12 @@ function vvScriptsFilterSet(type) {
vvRenderScripts(); vvRenderScripts();
} }
// Deep-link a script row to the Scheduler tab (opens its settings/log panel there).
function vvOpenScript(id) {
if (!id) { window.location.href = '?tab=scheduler'; return; }
window.location.href = '?tab=scheduler&vv_script=' + encodeURIComponent(id);
}
// ── Disk / storage helpers (module-level so vvRenderPools can call them) ───── // ── Disk / storage helpers (module-level so vvRenderPools can call them) ─────
function vvTempColor(tempC, transport) { function vvTempColor(tempC, transport) {
@@ -575,6 +584,16 @@ function vvPollMonitor() {
const _runningVMs = (d.vms?.vms ?? []).filter(v => v.state === 'running').length; const _runningVMs = (d.vms?.vms ?? []).filter(v => v.state === 'running').length;
const _threadInfo = sys.cpu_threads ? `${sys.cpu_cores}c / ${sys.cpu_threads}t` : ''; const _threadInfo = sys.cpu_threads ? `${sys.cpu_cores}c / ${sys.cpu_threads}t` : '';
// Load average (1/5/15m) — colour by load[0] vs core count
const _load = Array.isArray(sys.load_avg) ? sys.load_avg : null;
const _cores = sys.cpu_cores || 0;
const _loadColor = _load && _cores
? (_load[0] > _cores * 2 ? '#f44336' : _load[0] > _cores ? '#ff9800' : '#888') : '#888';
const _loadStr = _load
? `${_load[0].toFixed(2)} <span style="color:#444;">·</span> ${_load[1].toFixed(2)} <span style="color:#444;">·</span> ${_load[2].toFixed(2)}`
: '—';
const _coreMeta = _threadInfo ? ` <span style="color:#444;">(${_threadInfo})</span>` : '';
document.getElementById('vv-system-body').innerHTML = document.getElementById('vv-system-body').innerHTML =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;"> `<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<div style="min-width:0;"> <div style="min-width:0;">
@@ -608,9 +627,10 @@ function vvPollMonitor() {
<div style="font-size:20px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div> <div style="font-size:20px;font-weight:300;color:#ccc;line-height:1;">${timeStr}</div>
<div style="font-size:10px;color:#555;margin-bottom:10px;">${dateStr} &middot; ${tz}</div> <div style="font-size:10px;color:#555;margin-bottom:10px;">${dateStr} &middot; ${tz}</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;"> <div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;">
<span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.cpu_model}${_threadInfo ? ` <span style="color:#444;">(${_threadInfo})</span>` : ''}</span> <span style="color:#444;">Model</span> <span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${sys.cpu_model}${_coreMeta}</span>
<span style="color:#444;">Array</span> <span style="color:${arrayColor};font-weight:600;">${sys.array_state}</span> <span style="color:#444;">Array</span> <span style="color:${arrayColor};font-weight:600;">${sys.array_state}</span>
<span style="color:#444;">Uptime</span> <span style="color:#888;">${sys.uptime}</span> <span style="color:#444;">Uptime</span> <span style="color:#888;">${sys.uptime}</span>
<span style="color:#444;">Load</span> <span style="color:${_loadColor};">${_loadStr}</span>
<span style="color:#444;">Running</span> <span style="color:#888;">${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''}</span> <span style="color:#444;">Running</span> <span style="color:#888;">${_runningCtrs} ctr${_runningCtrs !== 1 ? 's' : ''}${_runningVMs > 0 ? ` · ${_runningVMs} VM` : ''}</span>
<span style="color:#444;">Version</span> <span style="color:#3a3a3a;">${ver}</span> <span style="color:#444;">Version</span> <span style="color:#3a3a3a;">${ver}</span>
</div>`; </div>`;
@@ -856,6 +876,8 @@ function vvPollMonitor() {
const maxSeen = Math.max(...vvNetRxHistory, ...vvNetTxHistory, 1); const maxSeen = Math.max(...vvNetRxHistory, ...vvNetTxHistory, 1);
const maxBps = maxSeen * 1.25; // auto-scale with 25% headroom const maxBps = maxSeen * 1.25; // auto-scale with 25% headroom
const peakRx = Math.max(...vvNetRxHistory, 0); // window peak (last 2 min)
const peakTx = Math.max(...vvNetTxHistory, 0);
const ipRows = [ const ipRows = [
net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${net.local_ip}</div>` : '', net.local_ip ? `<div><span style="color:#555;font-size:9px;">LAN&nbsp;&nbsp;</span>${net.local_ip}</div>` : '',
@@ -868,8 +890,8 @@ function vvPollMonitor() {
<div> <div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} &nbsp;·&nbsp; ${linkLabel}</div> <div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} &nbsp;·&nbsp; ${linkLabel}</div>
<div style="display:flex;gap:16px;font-size:13px;font-weight:600;"> <div style="display:flex;gap:16px;font-size:13px;font-weight:600;">
<span><span style="color:#4caf50;font-size:9px;margin-right:4px;">━ IN (RX)</span><span style="color:#4caf50;">${vvFmtBps(rx)}</span></span> <span><span style="color:#4caf50;font-size:9px;margin-right:4px;">━ IN (RX)</span><span style="color:#4caf50;">${vvFmtBps(rx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakRx)}</span></span>
<span><span style="color:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</span></span> <span><span style="color:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</span><span style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakTx)}</span></span>
</div> </div>
</div> </div>
<div style="text-align:right;font-size:11px;color:#aaa;line-height:1.6;">${ipRows}</div> <div style="text-align:right;font-size:11px;color:#aaa;line-height:1.6;">${ipRows}</div>
@@ -881,9 +903,15 @@ function vvPollMonitor() {
document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>'; document.getElementById('vv-network-body').innerHTML = '<p style="color:#555;font-style:italic">No network interface detected</p>';
} }
// ── CPU title ──────────────────────────────────────────────────────────── // ── CPU title (core count + temp to its right) ────────────────────────────
const _cpuTitleEl = document.getElementById('vv-cpu-title'); const _cpuTitleEl = document.getElementById('vv-cpu-title');
if (_cpuTitleEl && sys.cpu_threads) _cpuTitleEl.textContent = `CPU · ${sys.cpu_cores}c/${sys.cpu_threads}t`; if (_cpuTitleEl) {
const _cpuTemp = d.watchdog?.stability?.cpu_temp ?? null;
const _tColor = _cpuTemp == null ? '#888' : _cpuTemp >= 88 ? '#f44336' : _cpuTemp >= 75 ? '#ff9800' : '#4caf50';
const _coreLbl = sys.cpu_threads ? `CPU · ${sys.cpu_cores}c/${sys.cpu_threads}t` : 'CPU';
_cpuTitleEl.innerHTML = _coreLbl
+ (_cpuTemp != null ? ` <span style="color:${_tColor};font-weight:400;">${_cpuTemp}°</span>` : '');
}
// ── UPS / Power ───────────────────────────────────────────────────────── // ── UPS / Power ─────────────────────────────────────────────────────────
const ups = d.ups ?? {}; const ups = d.ups ?? {};
@@ -1072,13 +1100,15 @@ function vvPollMonitor() {
const sshdOk = stab.sshd_ok ?? true; const sshdOk = stab.sshd_ok ?? true;
const zombies = stab.zombies ?? 0; const zombies = stab.zombies ?? 0;
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11px;margin-top:8px;margin-bottom:6px;"> const cpuRow = stab.cpu_temp != null
? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : '';
let statsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:11px;margin-top:8px;margin-bottom:6px;">
<span style="color:#444;">rootfs</span><span style="color:${wdPct(stab.rootfs_pct??0,75,90)};">${stab.rootfs_pct??0}%</span> <span style="color:#444;">rootfs</span><span style="color:${wdPct(stab.rootfs_pct??0,75,90)};">${stab.rootfs_pct??0}%</span>
<span style="color:#444;">/var/log</span><span style="color:${wdPct(stab.log_pct??0,75,90)};">${stab.log_pct??0}%</span> <span style="color:#444;">/var/log</span><span style="color:${wdPct(stab.log_pct??0,75,90)};">${stab.log_pct??0}%</span>
<span style="color:#444;">/tmp</span><span style="color:${wdPct(stab.tmp_pct??0,75,90)};">${stab.tmp_pct??0}%</span> <span style="color:#444;">/tmp</span><span style="color:${wdPct(stab.tmp_pct??0,75,90)};">${stab.tmp_pct??0}%</span>
<span style="color:#444;">RAM free</span><span style="color:${ramColor};">${ramFree}GB</span> <span style="color:#444;">RAM free</span><span style="color:${ramColor};">${ramFree}GB</span>
<span style="color:#444;">Load</span><span style="color:${loadColor};">${load}</span> <span style="color:#444;">Load</span><span style="color:${loadColor};">${load}</span>
${stab.cpu_temp != null ? `<span style="color:#444;">CPU</span><span style="color:${wdPct(stab.cpu_temp,75,90)};">${stab.cpu_temp}°C</span>` : ''} ${cpuRow}
<span style="color:#444;">Zombies</span><span style="color:${zombies>0?'#ff9800':'#4caf50'};">${zombies}</span> <span style="color:#444;">Zombies</span><span style="color:${zombies>0?'#ff9800':'#4caf50'};">${zombies}</span>
<span style="color:#444;">${stab.nic??'nic'}</span><span style="color:${nicOk?'#4caf50':'#f44336'};">● ${stab.nic_state??'?'}</span> <span style="color:#444;">${stab.nic??'nic'}</span><span style="color:${nicOk?'#4caf50':'#f44336'};">● ${stab.nic_state??'?'}</span>
<span style="color:#444;">sshd</span><span style="color:${sshdOk?'#4caf50':'#f44336'};">${sshdOk?'● ok':'✗ down'}</span> <span style="color:#444;">sshd</span><span style="color:${sshdOk?'#4caf50':'#f44336'};">${sshdOk?'● ok':'✗ down'}</span>
@@ -1208,6 +1238,22 @@ function vvPollMonitor() {
const procCount = gpuProcs.length; const procCount = gpuProcs.length;
const procColor = procCount > 0 ? '#4caf50' : '#555'; const procColor = procCount > 0 ? '#4caf50' : '#555';
// Process list — which apps are actually using the GPU (name + VRAM)
let gpuProcHtml = '';
if (procCount > 0) {
const rows = gpuProcs.map(p => {
const pname = (p.name || '').split('/').pop() || p.name || 'proc';
return `<div style="display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px;">
<span style="color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">${pname}</span>
<span style="color:#555;flex-shrink:0;margin-left:6px;">${p.memory_mb} MB</span>
</div>`;
}).join('');
gpuProcHtml = `<div style="margin-top:6px;border-top:1px solid #2a2a2a;padding-top:6px;">
<div style="font-size:10px;color:#555;margin-bottom:3px;">GPU processes</div>
<div style="max-height:60px;overflow-y:auto;">${rows}</div>
</div>`;
}
document.getElementById('vv-gpu-body').innerHTML = document.getElementById('vv-gpu-body').innerHTML =
// header row: name + process count pill // header row: name + process count pill
`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;"> `<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
@@ -1224,7 +1270,7 @@ function vvPollMonitor() {
<span style="color:${tempColor};font-weight:bold;">${temp}°C</span> <span style="color:${tempColor};font-weight:bold;">${temp}°C</span>
<span style="color:#666;margin-left:12px;">Power</span> <span style="color:#666;margin-left:12px;">Power</span>
<span style="color:#aaa;font-weight:bold;">${powerStr}</span> <span style="color:#aaa;font-weight:bold;">${powerStr}</span>
</div>`; </div>` + gpuProcHtml;
} else { } else {
document.getElementById('vv-gpu-body').innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>'; document.getElementById('vv-gpu-body').innerHTML = '<p style="color:#555;font-style:italic">No GPU detected</p>';
} }
+405 -6
View File
@@ -32,6 +32,28 @@
0%,100% { box-shadow: 0 0 0 2px #4a8, 0 0 8px rgba(100,200,120,.2); } 0%,100% { box-shadow: 0 0 0 2px #4a8, 0 0 8px rgba(100,200,120,.2); }
50% { box-shadow: 0 0 0 4px #4a8, 0 0 18px rgba(100,200,120,.45); } 50% { box-shadow: 0 0 0 4px #4a8, 0 0 18px rgba(100,200,120,.45); }
} }
/* ── Settings panel ─────────────────────────────────────────── */
.vv-set-file { border:1px solid #222; border-radius:5px; margin-bottom:8px; overflow:hidden; }
.vv-set-file-hdr { display:flex; justify-content:space-between; align-items:center; padding:8px 12px;
background:#161616; cursor:pointer; font-size:12px; font-weight:bold; color:#bbb; font-family:monospace; }
.vv-set-file-hdr:hover { background:#1a1a1a; }
.vv-set-file-body { padding:6px 10px 10px; }
.vv-set-sec { border-top:1px solid #1c1c1c; }
.vv-set-sec:first-child { border-top:none; }
.vv-set-sec-hdr { display:flex; justify-content:space-between; align-items:center; padding:6px 4px;
cursor:pointer; font-size:11px; color:#888; text-transform:uppercase; letter-spacing:.04em; }
.vv-set-sec-hdr:hover { color:#ccc; }
.vv-set-sec-body { padding:4px 0 8px 8px; }
.vv-set-chev { color:#444; font-size:10px; }
.vv-set-field { margin-bottom:10px; }
.vv-set-key { font-size:11px; color:#7ab; font-family:monospace; margin-bottom:2px; }
.vv-set-desc { font-size:10px; color:#555; margin-bottom:3px; line-height:1.4; }
.vv-set-input { width:100%; box-sizing:border-box; background:#0d0d0d; border:1px solid #2a2a2a;
color:#ddd; padding:5px 8px; border-radius:3px; font-family:monospace; font-size:11px; }
.vv-set-input:focus { outline:none; border-color:#4a8; }
.vv-set-input.changed { border-color:#ff9800; }
textarea.vv-set-input { resize:vertical; white-space:pre; }
</style> </style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
@@ -44,17 +66,46 @@
<div id="vv-pt-config-body" style="color:#444;font-size:12px;">Loading…</div> <div id="vv-pt-config-body" style="color:#444;font-size:12px;">Loading…</div>
</div> </div>
<!-- Offline countdown warning (shown only when partner has been unreachable) -->
<div id="vv-pt-offline-warn" style="display:none;margin-bottom:12px;"></div>
<!-- Node grid --> <!-- Node grid -->
<div id="vv-pt-nodes" class="vv-pt-grid" style="margin-bottom:12px;"> <div id="vv-pt-nodes" class="vv-pt-grid" style="margin-bottom:12px;">
<div style="color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div> <div style="color:#444;font-size:12px;padding:16px 0;text-align:center;">Loading…</div>
</div> </div>
<!-- Mirror sync health -->
<div class="vv-card" id="vv-pt-sync-card" style="margin-bottom:12px;">
<h3>Mirror Sync</h3>
<div id="vv-pt-sync-body" style="color:#444;font-size:12px;">Loading…</div>
</div>
<!-- Actions --> <!-- Actions -->
<div class="vv-card" id="vv-pt-actions-card"> <div class="vv-card" id="vv-pt-actions-card">
<h3>Actions</h3> <h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div> <div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
</div> </div>
<!-- Settings -->
<div class="vv-card" id="vv-pt-settings-card" style="margin-top:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer;"
onclick="vvPtToggleSettings()">
<h3 style="margin:0;">Settings</h3>
<div style="display:flex;align-items:center;gap:10px;">
<button id="vv-pt-settings-save" class="vv-pt-action-btn run"
onclick="event.stopPropagation();vvPtSaveSettings(this)"
style="display:none;">Save Changes</button>
<span id="vv-pt-settings-toggle"
style="font-size:11px;color:#7ab;background:#1a2a3a;border:1px solid #2e4a6b;
border-radius:4px;padding:3px 10px;white-space:nowrap;"> Show settings</span>
</div>
</div>
<div id="vv-pt-settings-hint" style="font-size:10px;color:#444;margin-top:4px;">
Partnership configuration for this server.
</div>
<div id="vv-pt-settings-body" style="display:none;margin-top:10px;"></div>
</div>
<script> <script>
// ── Toggle states — persist across 10s poll re-renders ──────────────────────── // ── Toggle states — persist across 10s poll re-renders ────────────────────────
const _vvOnboarding = {}; // hostId → true when onboard steps are expanded const _vvOnboarding = {}; // hostId → true when onboard steps are expanded
@@ -96,6 +147,33 @@ function vvPtHideDeleteKeys(hostId) {
if (_vvPtReload) _vvPtReload(); if (_vvPtReload) _vvPtReload();
} }
function vvApiKey(btn) {
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = '⟳ Working…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => {
if (!r.ok || r.status === 0) throw new Error('HTTP ' + r.status + ' ' + r.statusText);
return r.text();
})
.then(text => {
if (!text.trim()) throw new Error('Empty response from server');
return JSON.parse(text);
})
.then(d => {
if (d.ok) {
if (_vvPtReload) _vvPtReload();
} else {
alert('Failed: ' + (d.error ?? 'Unknown error'));
btn.disabled = false; btn.textContent = origText;
}
})
.catch(e => {
alert('Error: ' + e);
btn.disabled = false; btn.textContent = origText;
});
}
function vvPtPhase2(btn, hostId) { function vvPtPhase2(btn, hostId) {
if (!confirm(`Phase 2: Deploy containers + arr stack + establish partnership on ${hostId}?\n\nRequires ${hostId} to have Varaverk installed and SSH keys set up.`)) return; if (!confirm(`Phase 2: Deploy containers + arr stack + establish partnership on ${hostId}?\n\nRequires ${hostId} to have Varaverk installed and SSH keys set up.`)) return;
btn.disabled = true; btn.disabled = true;
@@ -175,6 +253,176 @@ function vvPtOffboard(btn) {
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Offboard'; }, 4000)); .finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Offboard'; }, 4000));
} }
function vvPtTransfer(btn, token) {
if (!confirm('Transfer ownership to the mirror?\n\nThis promotes the mirror to OWNER and demotes this server. '
+ 'partnership_transfer.sh requires sustained health checks before the switch completes.\n\nContinue?')) return;
if (prompt('Type YES to confirm ownership transfer:') !== 'YES') return;
btn.disabled = true;
btn.textContent = '⟳ Transferring…';
_vvPtRun('Partnership/partnership_transfer.sh', '--confirm=' + token)
.then(d => { if (!d.ok) alert('Failed: ' + (d.error ?? 'Unknown error')); })
.catch(e => alert('Error: ' + e))
.finally(() => setTimeout(() => { btn.disabled = false; btn.textContent = '⇄ Transfer Ownership'; }, 4000));
}
function vvPtPing(btn, slot, idUpper) {
const out = document.getElementById('vv-pt-ping-' + idUpper);
btn.disabled = true;
btn.textContent = '⟳';
if (out) { out.textContent = ''; out.style.color = '#444'; }
fetch('/plugins/varaverk/api/partnership_ping.php?id=' + encodeURIComponent(slot) + '&_=' + Date.now())
.then(r => r.json())
.then(d => {
if (out) {
if (d.ok) { out.textContent = '✓ ' + d.latency_ms + 'ms'; out.style.color = '#4caf50'; }
else { out.textContent = '✗ ' + (d.error || 'failed'); out.style.color = '#f44336'; }
}
})
.catch(e => { if (out) { out.textContent = '✗ ' + e; out.style.color = '#f44336'; } })
.finally(() => { btn.disabled = false; btn.textContent = '⇄ Test'; });
}
function vvPtToggleSync(el, varName, enabled) {
el.style.pointerEvents = 'none';
el.style.opacity = '0.5';
const params = new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
name: varName,
enabled: String(enabled)
});
fetch('/plugins/varaverk/api/flag_toggle.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params
})
.then(r => r.json())
.then(d => {
if (d.ok) { if (_vvPtReload) _vvPtReload(); }
else { alert('Failed: ' + (d.error ?? 'Unknown error')); el.style.pointerEvents = ''; el.style.opacity = ''; }
})
.catch(e => { alert('Error: ' + e); el.style.pointerEvents = ''; el.style.opacity = ''; });
}
// ── Settings panel ─────────────────────────────────────────────────────────────
let _vvSetLoaded = false;
function _vvSetEsc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function vvPtToggleSettings() {
const body = document.getElementById('vv-pt-settings-body');
const toggle = document.getElementById('vv-pt-settings-toggle');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
toggle.textContent = open ? '▸ Show settings' : '▾ Hide settings';
if (!open && !_vvSetLoaded) vvPtLoadSettings();
}
function vvPtLoadSettings() {
const body = document.getElementById('vv-pt-settings-body');
body.innerHTML = '<div style="color:#555;font-size:12px;padding:8px 0;">Loading…</div>';
fetch('/plugins/varaverk/api/partnership_settings.php?_=' + Date.now())
.then(r => r.json())
.then(d => {
if (!d.ok || !(d.files || []).length) {
body.innerHTML = '<div style="color:#555;font-size:12px;padding:8px 0;">No settings available.</div>';
return;
}
// Host-aware hint: master.conf edits propagate; host config stays local.
const hasMaster = d.files.some(f => f.file === 'master.conf');
document.getElementById('vv-pt-settings-hint').textContent = hasMaster
? 'Shared (master.conf) edits propagate to partners on save · host config stays local to this server.'
: 'These settings apply to this host only.';
body.innerHTML = vvPtRenderSettings(d.files);
_vvSetLoaded = true;
})
.catch(e => { body.innerHTML = '<div style="color:#f88;font-size:12px;padding:8px 0;">Error: ' + e + '</div>'; });
}
function vvPtRenderSettings(files) {
let html = '';
for (const f of files) {
// One block per file; partnership scope yields a single section per file, so flatten.
const label = f.file === 'master.conf' ? 'master.conf — shared' : f.file + ' — this host';
html += `<div class="vv-set-file">
<div class="vv-set-file-hdr" onclick="vvPtToggleNode(this)">
<span>${_vvSetEsc(label)}</span><span class="vv-set-chev">▾</span>
</div>
<div class="vv-set-file-body">`;
for (const g of f.groups) {
for (const fld of g.fields) {
const attrs = `class="vv-set-input" data-key="${_vvSetEsc(fld.key)}" `
+ `data-file="${_vvSetEsc(fld.file)}" data-type="${_vvSetEsc(fld.type)}" `
+ `data-orig="${_vvSetEsc(fld.value)}" oninput="vvPtSetChanged(this)"`;
html += `<div class="vv-set-field">
<div class="vv-set-key">${_vvSetEsc(fld.key)}</div>`;
if (fld.desc) html += `<div class="vv-set-desc">${_vvSetEsc(fld.desc)}</div>`;
if (fld.type === 'scalar') {
html += `<input type="text" ${attrs} value="${_vvSetEsc(fld.value)}">`;
} else {
const rows = Math.min(16, (fld.value.match(/\n/g) || []).length + 2);
html += `<textarea ${attrs} rows="${rows}">${_vvSetEsc(fld.value)}</textarea>`;
}
html += `</div>`;
}
}
html += `</div></div>`;
}
return html;
}
function vvPtToggleNode(hdr) {
const body = hdr.nextElementSibling;
const chev = hdr.querySelector('.vv-set-chev');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : '';
if (chev) chev.textContent = open ? '▸' : '▾';
}
function vvPtSetChanged(el) {
const changed = el.value !== el.dataset.orig;
el.classList.toggle('changed', changed);
// Show Save button if any field is changed
const any = document.querySelector('#vv-pt-settings-body .vv-set-input.changed');
document.getElementById('vv-pt-settings-save').style.display = any ? '' : 'none';
}
function vvPtSaveSettings(btn) {
const changedEls = document.querySelectorAll('#vv-pt-settings-body .vv-set-input.changed');
if (!changedEls.length) return;
const changes = [];
changedEls.forEach(el => changes.push({
key: el.dataset.key, file: el.dataset.file, type: el.dataset.type, value: el.value
}));
if (!confirm(`Save ${changes.length} changed setting(s)?`)) return;
btn.disabled = true; btn.textContent = '⟳ Saving…';
const params = new URLSearchParams({
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
id: '__settings__',
changes: JSON.stringify(changes)
});
fetch('/plugins/varaverk/api/confform.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: params
})
.then(r => r.json())
.then(d => {
btn.disabled = false;
if (d.ok) {
// Commit new originals, clear highlights
changedEls.forEach(el => { el.dataset.orig = el.value; el.classList.remove('changed'); });
btn.textContent = '✓ Saved';
setTimeout(() => { btn.textContent = 'Save Changes'; btn.style.display = 'none'; }, 2000);
} else {
btn.textContent = 'Save Changes';
alert('Save failed: ' + (d.error ?? 'Unknown error'));
}
})
.catch(e => { btn.disabled = false; btn.textContent = 'Save Changes'; alert('Error: ' + e); });
}
// ── Private page logic ───────────────────────────────────────────────────────── // ── Private page logic ─────────────────────────────────────────────────────────
(function() { (function() {
@@ -202,6 +450,91 @@ function _row(lbl, val) {
return `<div class="vv-pt-row"><span class="vv-pt-lbl">${lbl}</span><span class="vv-pt-val">${val}</span></div>`; return `<div class="vv-pt-row"><span class="vv-pt-lbl">${lbl}</span><span class="vv-pt-val">${val}</span></div>`;
} }
// ── Offline countdown warning ───────────────────────────────────────────────────
function _renderOfflineWarn(cfg) {
const el = document.getElementById('vv-pt-offline-warn');
const days = cfg.offline_days;
const thr = cfg.offline_threshold || 30;
// Auto-offboard only runs for active partnerships — a stale counter while disabled is meaningless.
if (!cfg.enabled || !days || days < 1) { el.style.display = 'none'; el.innerHTML = ''; return; }
const left = Math.max(0, thr - days);
const crit = left <= 5;
const col = crit ? '#f44336' : '#ff9800';
const bg = crit ? '#2a1212' : '#1a1200';
const pct = Math.min(100, Math.round(days / thr * 100));
el.style.display = '';
el.innerHTML = `<div class="vv-card" style="background:${bg};border-color:${col};">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;flex-wrap:wrap;">
<span style="font-size:12px;color:${col};font-weight:600;">⚠ Partner unreachable ${days} day${days===1?'':'s'}</span>
<span style="font-size:11px;color:#888;">Auto-offboard in ${left} day${left===1?'':'s'} (threshold ${thr}d)</span>
</div>
<div style="margin-top:6px;height:5px;background:#000;border-radius:3px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${col};"></div>
</div>
</div>`;
}
// ── Mirror sync health ──────────────────────────────────────────────────────────
function _syncToggle(gate, dimmed) {
if (!gate) return '';
const on = gate.on;
const col = on ? '#4caf50' : '#f44336';
const bg = on ? '#0a1a0a' : '#2a1212';
const op = dimmed ? 'opacity:.4;' : '';
return `<span onclick="vvPtToggleSync(this,'${gate.var}',${on ? 0 : 1})"
style="cursor:pointer;font-size:9px;font-weight:600;padding:2px 9px;border-radius:10px;
background:${bg};color:${col};border:1px solid ${col};white-space:nowrap;margin-left:8px;${op}"
title="${gate.var} — click to ${on ? 'disable' : 'enable'}">${on ? 'ON' : 'OFF'}</span>`;
}
function _renderSync(sync) {
const jobs = sync.jobs || {};
const gates = sync.gates || {};
const sCol = s => s === 'ok' ? '#4caf50' : s === 'running' ? '#4a9eff'
: s === 'warn' ? '#ff9800' : s === 'error' ? '#f44336' : '#555';
const sLbl = s => s === 'ok' ? '✓ ok' : s === 'running' ? '⟳ running'
: s === 'warn' ? '⚠ warn' : s === 'error' ? '✗ error' : '— never';
const globalOff = gates.global && !gates.global.on;
// Master toggle row — controls all mirroring (RSYNC_ENABLED, Tier 1).
let html = '';
if (gates.global) {
html += `<div class="vv-pt-row" style="margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid #1c1c1c;">
<span class="vv-pt-lbl" style="font-weight:600;color:#999;">All mirroring</span>
<span class="vv-pt-val">${_syncToggle(gates.global, false)}</span>
</div>`;
}
// Per-tier rows: status + last-run + per-tier toggle (dimmed when global is off).
const labels = {critical: 'Critical (30 min)', daily: 'Daily', weekly: 'Weekly'};
for (const key of ['critical', 'daily', 'weekly']) {
const j = jobs[key];
const status = j ? j.status : null;
const when = j && j.start
? (j.status === 'running' ? 'started ' + _relTime(j.start)
: _relTime(j.end || j.start))
: '—';
html += `<div class="vv-pt-row">
<span class="vv-pt-lbl">${labels[key]}</span>
<span class="vv-pt-val">
<span style="color:${sCol(status)};">${sLbl(status)}</span>
<span style="color:#444;font-size:10px;margin-left:6px;">${when}</span>
${_syncToggle(gates[key], globalOff)}
</span>
</div>`;
}
if (globalOff) {
html += `<div style="font-size:10px;color:#f44336;margin-top:6px;">⚠ All mirroring is off — per-tier switches have no effect until re-enabled.</div>`;
}
return html;
}
// ── Config bar ──────────────────────────────────────────────────────────────── // ── Config bar ────────────────────────────────────────────────────────────────
function _renderConfig(cfg) { function _renderConfig(cfg) {
@@ -286,9 +619,56 @@ function _nodeCard(node) {
node.key_ready ? `<div style="font-size:10px;color:#ff9800;margin-bottom:6px;padding:2px 6px;background:#1a1200;border:1px solid #3a2800;border-radius:3px;">🔑 Key generated · install on HOST2 then Push Conf</div>` : node.key_ready ? `<div style="font-size:10px;color:#ff9800;margin-bottom:6px;padding:2px 6px;background:#1a1200;border:1px solid #3a2800;border-radius:3px;">🔑 Key generated · install on HOST2 then Push Conf</div>` :
`<div style="font-size:10px;color:#555;margin-bottom:6px;padding:2px 6px;background:#111;border:1px solid #222;border-radius:3px;">○ Not yet provisioned</div>`); `<div style="font-size:10px;color:#555;margin-bottom:6px;padding:2px 6px;background:#111;border:1px solid #222;border-radius:3px;">○ Not yet provisioned</div>`);
const apiKeyHtml = node.is_me
? (node.api_key_set
? `<div style="font-size:10px;color:#2a4a2a;margin-bottom:6px;padding:2px 6px;background:#0a1a0a;
border:1px solid #1a3a1a;border-radius:3px;display:flex;justify-content:space-between;align-items:center;">
<span style="color:#3a7a3a;">🔑 API key: <span style="color:#4caf50;">${node.api_key_preview}</span></span>
<button onclick="vvApiKey(this)"
style="font-size:9px;padding:1px 6px;background:#0d0d0d;color:#444;border:1px solid #222;
border-radius:2px;cursor:pointer;margin-left:6px;white-space:nowrap;">↻ renew</button>
</div>`
: `<div style="font-size:10px;color:#ff9800;margin-bottom:6px;padding:2px 6px;background:#1a1200;
border:1px solid #3a2800;border-radius:3px;display:flex;justify-content:space-between;align-items:center;">
<span>⚡ No API key — Monitor stats unavailable</span>
<button onclick="vvApiKey(this)"
style="font-size:9px;padding:1px 8px;background:#1a3a1a;color:#6fcf97;border:1px solid #2e6b2e;
border-radius:2px;cursor:pointer;margin-left:6px;white-space:nowrap;">+ Create</button>
</div>`)
: '';
// ── Metrics strip ────────────────────────────────────────────────────────────
const m = node.metrics || {};
function _ptCol(v, warn, crit) {
return v >= crit ? '#f44336' : v >= warn ? '#ff9800' : '#4caf50';
}
const metricPairs = [];
if (m.cpu_pct != null) metricPairs.push(['CPU', `<span style="color:${_ptCol(m.cpu_pct,70,90)};">${m.cpu_pct}%</span>`]);
if (m.ram_used_gb != null) {
const ramPct = m.ram_total_gb ? Math.round(m.ram_used_gb / m.ram_total_gb * 100) : 0;
metricPairs.push(['RAM', `<span style="color:${_ptCol(ramPct,75,90)};">${m.ram_used_gb}<span style="color:#444;font-size:9px;">/${m.ram_total_gb}GB</span></span>`]);
}
if (m.load_avg != null) metricPairs.push(['Load', `<span style="color:${m.load_avg>8?'#f44336':m.load_avg>4?'#ff9800':'#4caf50'};">${m.load_avg}</span>`]);
if (m.containers != null) metricPairs.push(['Conts', `<span style="color:#bbb;">${m.containers}</span>`]);
if (m.array_used_tb != null) metricPairs.push(['Array', `<span style="color:#bbb;">${m.array_used_tb}<span style="color:#444;font-size:9px;">/${m.array_total_tb}TB</span></span>`]);
if (m.max_disk_temp != null) metricPairs.push(['Temp', `<span style="color:${_ptCol(m.max_disk_temp,55,70)};">${m.max_disk_temp}°C</span>`]);
if (m.vm_count != null && m.vm_count > 0) metricPairs.push(['VMs', `<span style="color:#bbb;">${m.vm_count}</span>`]);
let metricsHtml = '';
if (metricPairs.length) {
let cells = '';
for (const [lbl, val] of metricPairs) {
cells += `<span style="color:#555;">${lbl}</span>${val}`;
}
if (metricPairs.length % 2 !== 0) cells += '<span></span><span></span>';
metricsHtml = `<div style="display:grid;grid-template-columns:auto 1fr auto 1fr;gap:2px 8px;font-size:10px;margin-bottom:6px;">${cells}</div>`;
}
let body = ''; let body = '';
body += phaseHtml; body += phaseHtml;
body += apiKeyHtml;
body += metricsHtml;
// Network // Network
body += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;"> body += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
@@ -337,7 +717,7 @@ function _renderActions(nodes, cfg) {
const remotes = nodes.filter(n => !n.is_me); const remotes = nodes.filter(n => !n.is_me);
const hasPartner = remotes.some(n => n.hostname); const hasPartner = remotes.some(n => n.hostname);
const termBase = `https://${window.location.hostname}/webterminal/ttyd/`; const termBase = `https://${window.location.hostname}/webterminal/ttyd/`;
const termCmd = `bash /mnt/user/appdata/Varaverk/Partnership/partnership_onboard.sh --phase1-only`; const termCmd = `bash <?= SCRIPTS_DIR ?>/Partnership/partnership_onboard.sh --phase1-only`;
let html = ''; let html = '';
@@ -365,13 +745,18 @@ function _renderActions(nodes, cfg) {
html += `<div style="margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #1e1e1e;">`; html += `<div style="margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #1e1e1e;">`;
// Header: host + online dot // Header: host + online dot + test connection
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"> html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div> <div>
<span style="font-size:10px;color:#444;">${remote.id}</span> <span style="font-size:10px;color:#444;">${remote.id}</span>
<span style="font-size:12px;color:#bbb;font-weight:500;margin-left:5px;">${remote.hostname}</span> <span style="font-size:12px;color:#bbb;font-weight:500;margin-left:5px;">${remote.hostname}</span>
</div> </div>
<span style="font-size:10px;color:${dotCol};">● ${dotLbl}</span> <div style="display:flex;align-items:center;gap:8px;">
<span id="vv-pt-ping-${remote.id}" style="font-size:10px;color:#444;"></span>
<button class="vv-pt-action-btn info" onclick="vvPtPing(this,'${remote.slot}','${remote.id}')"
style="font-size:10px;padding:2px 8px;" title="SSH echo round-trip">⇄ Test</button>
<span style="font-size:10px;color:${dotCol};">● ${dotLbl}</span>
</div>
</div>`; </div>`;
const isDeleting = !!_vvDeleteKeys[remote.id]; const isDeleting = !!_vvDeleteKeys[remote.id];
@@ -516,10 +901,18 @@ function _renderActions(nodes, cfg) {
</button></div>`; </button></div>`;
if (cfg.enabled && isOwner) { if (cfg.enabled && isOwner) {
const tok = (cfg.transfer_confirm || 'i-understand-this-transfers-ownership')
.replace(/'/g, "\\'");
html += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid #1e1e1e;"> html += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid #1e1e1e;">
<span style="font-size:11px;color:#555;">Transfer ownership run manually:</span> <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<div class="vv-pt-transfer-note" style="margin-top:4px;padding:6px 10px;background:#111;border-radius:4px;color:#555;"> <span style="font-size:11px;color:#555;">Transfer ownership to the mirror promotes it to owner.</span>
bash Partnership/partnership_transfer.sh --confirm=i-understand-this-transfers-ownership <button class="vv-pt-action-btn warn" onclick="vvPtTransfer(this, '${tok}')"
title="Runs partnership_transfer.sh — requires sustained health checks before switching">
Transfer Ownership
</button>
</div>
<div class="vv-pt-transfer-note" style="margin-top:6px;padding:6px 10px;background:#111;border-radius:4px;color:#444;">
Or run manually: bash Partnership/partnership_transfer.sh --confirm=${cfg.transfer_confirm || 'i-understand-this-transfers-ownership'}
</div> </div>
</div>`; </div>`;
} }
@@ -536,6 +929,12 @@ function _render(data) {
// Config bar // Config bar
document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg); document.getElementById('vv-pt-config-body').innerHTML = _renderConfig(cfg);
// Offline countdown warning
_renderOfflineWarn(cfg);
// Mirror sync health
document.getElementById('vv-pt-sync-body').innerHTML = _renderSync(data.sync || {});
// Node grid — columns based on count // Node grid — columns based on count
const cols = nodes.length <= 2 ? nodes.length : nodes.length <= 4 ? 2 : 3; const cols = nodes.length <= 2 ? nodes.length : nodes.length <= 4 ? 2 : 3;
const grid = document.getElementById('vv-pt-nodes'); const grid = document.getElementById('vv-pt-nodes');
+20 -2
View File
@@ -4,6 +4,10 @@ require_once dirname(__DIR__) . '/include/scheduler.php';
// Setup mode — auto-open a conf file and force the editing sequence // Setup mode — auto-open a conf file and force the editing sequence
$vv_setup_conf = preg_match('/^[\w.]+\.conf$/', $_GET['vv_setup'] ?? '') $vv_setup_conf = preg_match('/^[\w.]+\.conf$/', $_GET['vv_setup'] ?? '')
? $_GET['vv_setup'] : ''; ? $_GET['vv_setup'] : '';
// Deep-link from Monitor → open a specific script on load (e.g. ?tab=scheduler&vv_script=Media/x.sh)
$vv_open_script = preg_match('#^[\w./-]+\.sh$#', $_GET['vv_script'] ?? '')
? $_GET['vv_script'] : '';
$_vv_my_host = vv_detect_host(); $_vv_my_host = vv_detect_host();
$_vv_local_host_conf = ($_vv_my_host !== 'unknown') ? $_vv_my_host . '.conf' : 'host1.conf'; $_vv_local_host_conf = ($_vv_my_host !== 'unknown') ? $_vv_my_host . '.conf' : 'host1.conf';
@@ -959,6 +963,20 @@ if (vvSetupConf) {
}); });
} }
// Deep-link from Monitor: open a specific script's panel once the tree is rendered.
const vvOpenScriptId = <?= json_encode($vv_open_script) ?>;
if (vvOpenScriptId) {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
if (document.querySelector('[data-id="' + CSS.escape(vvOpenScriptId) + '"]')) {
vvOpenRight(vvOpenScriptId);
const row = document.querySelector('[data-id="' + CSS.escape(vvOpenScriptId) + '"]');
if (row) row.scrollIntoView({behavior: 'smooth', block: 'center'});
}
}, 200);
});
}
function vvEscHtml(s) { function vvEscHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
@@ -1995,13 +2013,13 @@ function vvUpdateErrors(errors) {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
let html = '<div class="vv-errors-list">'; let html = '<div class="vv-errors-list">';
for (const e of unacked) { for (const e of unacked) {
const jobId = JSON.stringify(e.script + '.sh'); const jobId = "'" + (e.script + '.sh').replace(/\\/g,"\\\\").replace(/'/g,"\\'") + "'";
const label = e.script.split('/').pop().replace(/_/g, ' '); const label = e.script.split('/').pop().replace(/_/g, ' ');
html += '<div class="vv-err-row">' html += '<div class="vv-err-row">'
+ '<div class="vv-err-top">' + '<div class="vv-err-top">'
+ '<span class="vv-err-script" onclick="vvOpenRight(' + jobId + ')" style="cursor:pointer" title="' + vvEscHtml(e.script) + '">' + vvEscHtml(label) + '</span>' + '<span class="vv-err-script" onclick="vvOpenRight(' + jobId + ')" style="cursor:pointer" title="' + vvEscHtml(e.script) + '">' + vvEscHtml(label) + '</span>'
+ '<span class="vv-err-age">' + vvFmtAge(now - e.ts) + '</span>' + '<span class="vv-err-age">' + vvFmtAge(now - e.ts) + '</span>'
+ '<button class="vv-btn-sm vv-ack-btn" onclick="vvAckError(' + JSON.stringify(e.script) + ',' + e.ts + ',this)">Ack</button>' + '<button class="vv-btn-sm vv-ack-btn" onclick="vvAckError(\'' + e.script.replace(/\\/g,"\\\\").replace(/'/g,"\\'") + "'," + e.ts + ',this)">Ack</button>'
+ '</div>' + '</div>'
+ '<div class="vv-err-line">' + vvEscHtml(e.line) + '</div>' + '<div class="vv-err-line">' + vvEscHtml(e.line) + '</div>'
+ '</div>'; + '</div>';
+60 -6
View File
@@ -93,8 +93,8 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
}).then(r => r.json()).then(d => { }).then(r => r.json()).then(d => {
if (d.ok) { if (d.ok) {
status.textContent = '✓ Created — loading…'; status.className = 'ok'; status.textContent = '✓ Created'; status.className = 'ok';
setTimeout(() => { window.location.href = '?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>); }, 600); vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>));
} else { } else {
btn.disabled = false; btn.textContent = 'Create <?= htmlspecialchars($myHostId) ?>.conf and continue →'; btn.disabled = false; btn.textContent = 'Create <?= htmlspecialchars($myHostId) ?>.conf and continue →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err'; status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
@@ -151,8 +151,8 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
}).then(r => r.json()).then(d => { }).then(r => r.json()).then(d => {
if (d.ok) { if (d.ok) {
status.textContent = '✓ Configuration pulled — loading…'; status.className = 'ok'; status.textContent = '✓ Configuration pulled'; status.className = 'ok';
setTimeout(() => { window.location.href = d.redirect ?? '?tab=scheduler'; }, 600); vvShowStep2(d.redirect ?? '?tab=scheduler');
} else { } else {
btn.disabled = false; btn.textContent = 'Pull configuration from HOST1 →'; btn.disabled = false; btn.textContent = 'Pull configuration from HOST1 →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err'; status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
@@ -244,8 +244,8 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
}).then(r => r.json()).then(d => { }).then(r => r.json()).then(d => {
if (d.ok) { if (d.ok) {
status.textContent = '✓ Saved — loading…'; status.className = 'ok'; status.textContent = '✓ Saved'; status.className = 'ok';
setTimeout(() => { window.location.href = d.redirect ?? '?tab=scheduler&vv_setup=master.conf'; }, 600); vvShowStep2(d.redirect ?? '?tab=scheduler&vv_setup=master.conf');
} else { } else {
btn.disabled = false; btn.textContent = 'Save and continue →'; btn.disabled = false; btn.textContent = 'Save and continue →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err'; status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
@@ -255,4 +255,58 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
</script> </script>
<?php endif; ?> <?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> </div>
+141 -131
View File
@@ -2,18 +2,26 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "varaverk"> <!ENTITY name "varaverk">
<!ENTITY author "gmer4lfe"> <!ENTITY author "gmer4lfe">
<!ENTITY version "2026.05.30"> <!ENTITY version "2026.05.31">
<!ENTITY sha256 "932468c8a114134dea3062f2810a7ca5a7b1168e1e78d0195c41c61b86db7728">
<!ENTITY launch "varaverk/monitor"> <!ENTITY launch "varaverk/monitor">
<!ENTITY github "https://github.com/FailedProxy/Varaverk"> <!ENTITY github "https://github.com/FailedProxy/Varaverk">
<!ENTITY branch "main"> <!ENTITY branch "main">
<!ENTITY cfgdir "/boot/config/plugins/varaverk"> <!ENTITY cfgdir "/boot/config/plugins/varaverk">
<!ENTITY plugdir "/usr/local/emhttp/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;" <PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;"
support="https://github.com/FailedProxy/Varaverk/issues" support="https://github.com/FailedProxy/Varaverk/issues"
icon="/plugins/varaverk/icons/varaverk.png"> icon="/plugins/varaverk/icons/varaverk.png">
<CHANGES> <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 ###2026.05.30
- First-run setup wizard: auto-detects hostname, creates master.conf + host conf from templates - 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 - Scheduler setup mode: after wizard, master.conf and host conf open sequentially with forced save flow
@@ -33,143 +41,153 @@
</CHANGES> </CHANGES>
<!-- <!--
── Install / Update ──────────────────────────────────────────────────────────── ── 1. Web files package (runs on every boot) ──────────────────────────────────
Runs on first install and on every plugin update. unRAID runs `plugin install` on every .plg at boot, BEFORE the array mounts.
Clones the repo on fresh install; pulls latest on update. The .txz lives on flash, so this installs the PHP/JS UI to RAM in time — and the
Array must be started — appdata must be available. 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;/install.sh" Run="/bin/bash" Method="install update"> <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> <INLINE>
<![CDATA[ <![CDATA[
#!/bin/bash #!/bin/bash
PLUGIN="varaverk" PLUGIN="varaverk"
GITHUB="https://github.com/FailedProxy/Varaverk"
BRANCH="main"
CFG_DIR="/boot/config/plugins/$PLUGIN" CFG_DIR="/boot/config/plugins/$PLUGIN"
CFG_FILE="$CFG_DIR/varaverk.cfg" CFG_FILE="$CFG_DIR/varaverk.cfg"
WEB_DIR="/usr/local/emhttp/plugins/$PLUGIN" GITHUB="https://github.com/FailedProxy/Varaverk"
BRANCH="main"
LOG="$CFG_DIR/install.log" LOG="$CFG_DIR/install.log"
mkdir -p "$CFG_DIR" mkdir -p "$CFG_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; } log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; }
log "=== Varaverk install/update ===" # Scripts live in the plugin dir on flash — no array needed.
SCRIPTS_DIR="$CFG_DIR"
# Unraid only
if [[ ! -f /etc/unraid-version ]]; then
log "ERROR: not running on Unraid — aborting"
exit 1
fi
# Read SCRIPTS_DIR from cfg if present
if [[ -f "$CFG_FILE" ]]; then
_sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
[[ -n "$_sd" ]] && SCRIPTS_DIR="$_sd"
fi
SCRIPTS_DIR="${SCRIPTS_DIR:-/mnt/user/appdata/Varaverk}"
CONF_DIR="$SCRIPTS_DIR/Configurations" CONF_DIR="$SCRIPTS_DIR/Configurations"
# Array must be started — appdata must be available # Seed varaverk.cfg with defaults (SCRIPTS_DIR + Gitea settings) if not present.
if ! df --output=fstype /mnt/user 2>/dev/null | grep -q 'shfs'; then
log "Array not started — cannot install to appdata"
log "Start the array then reinstall, or wait for next boot"
exit 0
fi
# Clone or update repo
if [[ ! -d "$SCRIPTS_DIR/.git" ]]; then
log "Cloning $GITHUB ($BRANCH) → $SCRIPTS_DIR"
if ! git clone --branch "$BRANCH" "$GITHUB" "$SCRIPTS_DIR" >> "$LOG" 2>&1; then
log "ERROR: git clone failed — check internet access and GitHub URL"
exit 1
fi
log "Clone complete"
else
log "Updating repo in $SCRIPTS_DIR"
git -C "$SCRIPTS_DIR" pull --ff-only origin "$BRANCH" >> "$LOG" 2>&1 \
&& log "Pull complete" \
|| log "WARNING: git pull failed — repo may have local modifications"
fi
# Sanity check — Plugin/unraid must exist after clone
if [[ ! -d "$SCRIPTS_DIR/Plugin/unraid" ]]; then
log "ERROR: Plugin/unraid not found in cloned repo — unexpected repo structure"
exit 1
fi
# Symlink web files
if [[ -L "$WEB_DIR" ]]; then
rm "$WEB_DIR"
elif [[ -d "$WEB_DIR" ]]; then
log "WARNING: $WEB_DIR is a real directory — not replacing (remove manually if needed)"
fi
if [[ ! -e "$WEB_DIR" ]]; then
ln -s "$SCRIPTS_DIR/Plugin/unraid" "$WEB_DIR"
log "Symlinked: $WEB_DIR → $SCRIPTS_DIR/Plugin/unraid"
fi
# Write varaverk.cfg if not present
if [[ ! -f "$CFG_FILE" ]]; then if [[ ! -f "$CFG_FILE" ]]; then
printf 'SCRIPTS_DIR="%s"\n' "$SCRIPTS_DIR" > "$CFG_FILE" cat > "$CFG_FILE" <<'CFGEOF'
log "Created varaverk.cfg (SCRIPTS_DIR=$SCRIPTS_DIR)" 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 fi
# Create Configurations dir if git clone didn't # Read Gitea settings from varaverk.cfg (allows override without editing .plg).
mkdir -p "$CONF_DIR" _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")
# Bootstrap master.conf from template on first install # Clone on first install only; never auto-pull (updates via the UI git pull).
if [[ ! -f "$CONF_DIR/master.conf" ]]; then if [[ ! -d "$SCRIPTS_DIR/.git" ]]; then
if [[ -f "$SCRIPTS_DIR/Configurations/master.conf.template" ]]; then log "initialising repo in $SCRIPTS_DIR ($BRANCH)..."
cp "$SCRIPTS_DIR/Configurations/master.conf.template" "$CONF_DIR/master.conf"
log "Created master.conf from template — fill in HOST1 and HOST2 to continue" # Locate Gitea: local container → local IP; else Tailscale; else fall back to GitHub.
else GITEA_IP=""
log "WARNING: master.conf.template not found — master.conf not created" 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 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
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
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 fi
log "Install complete — open Varaverk in Unraid to complete setup" # Seed master.conf from template if absent.
log "→ Plugins → Varaverk (or navigate to Settings → Utilities → Varaverk)" 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> </INLINE>
</FILE> </FILE>
<!-- <!--
── Boot (every boot) ─────────────────────────────────────────────────────────── ── 3. Remove ───────────────────────────────────────────────────────────────────
Fast idempotent check — recreates the web symlink if it disappeared. Stops background scripts, removes cron, the installed package, and flash config.
No git operations. Safe to run before the array is fully started. Scripts/conf in appdata are left intact (delete manually for a full wipe).
──────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────
--> -->
<FILE Name="&cfgdir;/boot.sh" Run="/bin/bash"> <FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
#!/bin/bash
PLUGIN="varaverk"
CFG_FILE="/boot/config/plugins/$PLUGIN/varaverk.cfg"
WEB_DIR="/usr/local/emhttp/plugins/$PLUGIN"
# Read SCRIPTS_DIR
if [[ -f "$CFG_FILE" ]]; then
_sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
fi
SCRIPTS_DIR="${_sd:-/mnt/user/appdata/Varaverk}"
# Only act if source exists (array started) and symlink is missing
if [[ -d "$SCRIPTS_DIR/Plugin/unraid" ]] && [[ ! -e "$WEB_DIR" ]]; then
[[ -L "$WEB_DIR" ]] && rm "$WEB_DIR"
ln -s "$SCRIPTS_DIR/Plugin/unraid" "$WEB_DIR"
fi
]]>
</INLINE>
</FILE>
<!--
── Remove ──────────────────────────────────────────────────────────────────────
Removes the web symlink. Scripts and conf in appdata are left intact.
────────────────────────────────────────────────────────────────────────────────
-->
<FILE Name="&cfgdir;/remove.sh" Run="/bin/bash" Method="remove">
<INLINE> <INLINE>
<![CDATA[ <![CDATA[
#!/bin/bash #!/bin/bash
@@ -178,44 +196,36 @@ CFG_DIR="/boot/config/plugins/$PLUGIN"
CFG_FILE="$CFG_DIR/varaverk.cfg" CFG_FILE="$CFG_DIR/varaverk.cfg"
CRON_FILE="$CFG_DIR/varaverk.cron" CRON_FILE="$CFG_DIR/varaverk.cron"
WEB_DIR="/usr/local/emhttp/plugins/$PLUGIN" WEB_DIR="/usr/local/emhttp/plugins/$PLUGIN"
log() { echo "[Varaverk remove] $*"; } log() { echo "[Varaverk remove] $*"; }
# Read SCRIPTS_DIR so we can locate running scripts [[ -f "$CFG_FILE" ]] && _sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
if [[ -f "$CFG_FILE" ]]; then SCRIPTS_DIR="${_sd:-$CFG_DIR}"
_sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
fi
SCRIPTS_DIR="${_sd:-/mnt/user/appdata/Varaverk}"
# ── Stop continuous background scripts ─────────────────────────────────────── # Stop continuous background scripts.
# fallback.sh manages its own lock — use --stop for clean shutdown
if [[ -f "$SCRIPTS_DIR/Fallback/fallback.sh" ]]; then if [[ -f "$SCRIPTS_DIR/Fallback/fallback.sh" ]]; then
bash "$SCRIPTS_DIR/Fallback/fallback.sh" --stop 2>/dev/null && \ bash "$SCRIPTS_DIR/Fallback/fallback.sh" --stop 2>/dev/null && log "fallback.sh stopped" || true
log "fallback.sh stopped" || true
fi fi
# Kill any remaining Varaverk background processes (watchdog orchestrator, etc.)
pkill -f "run_job.sh" 2>/dev/null || true pkill -f "run_job.sh" 2>/dev/null || true
pkill -f "watchdog_orchestrator.sh" 2>/dev/null || true pkill -f "watchdog_orchestrator.sh" 2>/dev/null || true
# ── Remove cron entries ─────────────────────────────────────────────────────── # Remove cron entries.
if [[ -f "$CRON_FILE" ]]; then if [[ -f "$CRON_FILE" ]]; then
rm -f "$CRON_FILE" rm -f "$CRON_FILE"
/usr/local/sbin/update_cron 2>/dev/null || true /usr/local/sbin/update_cron 2>/dev/null || true
log "Cron entries removed" log "cron removed"
fi fi
# Remove legacy direct cron file if it exists
rm -f /etc/cron.d/varaverk rm -f /etc/cron.d/varaverk
# ── Remove web symlink ──────────────────────────────────────────────────────── # Remove the installed package (and its RAM files).
[[ -L "$WEB_DIR" ]] && rm -f "$WEB_DIR" && log "Web symlink removed" 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 plugin config from flash ────────────────────────────────────────── # Remove flash config (incl. cached .txz).
rm -rf "$CFG_DIR" rm -rf "$CFG_DIR"
log "Plugin config removed from flash" log "flash config removed"
log "done — scripts/conf in $SCRIPTS_DIR preserved (delete manually for full wipe)"
log "Done. Scripts and conf in $SCRIPTS_DIR are preserved."
log "To fully remove: delete $SCRIPTS_DIR manually."
]]> ]]>
</INLINE> </INLINE>
</FILE> </FILE>
+1 -1
View File
@@ -184,7 +184,7 @@ if ! check_rsync_enabled; then
fi fi
# Blocklist gate — refuse to sync with a partner blocked after offboard # Blocklist gate — refuse to sync with a partner blocked after offboard
BLOCKLIST_FILE="/boot/config/partnership_blocklist.db" BLOCKLIST_FILE="${PARTNERSHIP_BLOCKLIST_FILE:-${STATE_DIR:-/boot/config}/partnership_blocklist.db}"
if [[ -f "$BLOCKLIST_FILE" ]] && grep -q "^${REMOTE_SERVER_NAME}|" "$BLOCKLIST_FILE" 2>/dev/null; then if [[ -f "$BLOCKLIST_FILE" ]] && grep -q "^${REMOTE_SERVER_NAME}|" "$BLOCKLIST_FILE" 2>/dev/null; then
error "Rsync blocked — $REMOTE_SERVER_NAME is on the partnership blocklist" error "Rsync blocked — $REMOTE_SERVER_NAME is on the partnership blocklist"
error "Re-onboard the partnership to restore access: partnership_manager.sh --onboard" error "Re-onboard the partnership to restore access: partnership_manager.sh --onboard"
+1 -1
View File
@@ -110,7 +110,7 @@ acquire_lock
detect_hosts detect_hosts
STATE_FILE="/tmp/transcode_state.db" STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)" log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
log "Ramdisk: $RAMDISK_PATH" log "Ramdisk: $RAMDISK_PATH"
+1 -1
View File
@@ -327,7 +327,7 @@ fi
# ━━━ Initialise State File ━━━ # ━━━ Initialise State File ━━━
# ============================================================================================== # ==============================================================================================
if [[ "$DRY_RUN" == false ]]; then if [[ "$DRY_RUN" == false ]]; then
STATE_FILE="/tmp/transcode_state.db" STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
NOW=$(date +%s) NOW=$(date +%s)
cat > "$STATE_FILE" <<EOF cat > "$STATE_FILE" <<EOF
current_target=$RAMDISK_PATH current_target=$RAMDISK_PATH
+1 -1
View File
@@ -102,7 +102,7 @@ source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@" parse_args "$@"
STATE_FILE="/tmp/transcode_state.db" STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
# ============================================================================================== # ==============================================================================================
# ━━━ Setup ━━━ # ━━━ Setup ━━━
+3 -3
View File
@@ -140,7 +140,7 @@ done
parse_args "${FILTERED_ARGS[@]}" parse_args "${FILTERED_ARGS[@]}"
DOCKER_TIMEOUT=15 DOCKER_TIMEOUT=15
STATE_FILE="/tmp/transcode_state.db" STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
# ============================================================================================== # ==============================================================================================
# ━━━ Setup ━━━ # ━━━ Setup ━━━
@@ -250,7 +250,7 @@ get_ssd_free_gb() {
} }
get_flip_count() { get_flip_count() {
local state_file="/tmp/transcode_flip_state.db" local state_file="${STATE_DIR:-/tmp}/transcode_flip_state.db"
local current_hour local current_hour
current_hour=$(date '+%Y-%m-%d-%H') current_hour=$(date '+%Y-%m-%d-%H')
[[ ! -f "$state_file" ]] && echo "0" && return [[ ! -f "$state_file" ]] && echo "0" && return
@@ -261,7 +261,7 @@ get_flip_count() {
} }
increment_flip_count() { increment_flip_count() {
local state_file="/tmp/transcode_flip_state.db" local state_file="${STATE_DIR:-/tmp}/transcode_flip_state.db"
local current_hour local current_hour
current_hour=$(date '+%Y-%m-%d-%H') current_hour=$(date '+%Y-%m-%d-%H')
local current_count local current_count
+5 -2
View File
@@ -1246,11 +1246,14 @@ get_rsync_opts() {
LOCK_DIR="/tmp/unraid_locks" LOCK_DIR="/tmp/unraid_locks"
# Ensure DATA_DIR exists — created here so every script that sources common.sh # Ensure DATA_DIR and STATE_DIR exist — created here so every script that sources common.sh
# can safely write to it without checking first # can safely write to either without checking first.
if [[ -n "${DATA_DIR:-}" ]] && [[ ! -d "$DATA_DIR" ]]; then if [[ -n "${DATA_DIR:-}" ]] && [[ ! -d "$DATA_DIR" ]]; then
mkdir -p "$DATA_DIR" 2>/dev/null || true mkdir -p "$DATA_DIR" 2>/dev/null || true
fi fi
if [[ -n "${STATE_DIR:-}" ]] && [[ ! -d "$STATE_DIR" ]]; then
mkdir -p "$STATE_DIR" 2>/dev/null || true
fi
RSYNC_COUNT_FILE="$LOCK_DIR/rsync_active_count" RSYNC_COUNT_FILE="$LOCK_DIR/rsync_active_count"
RSYNC_MAX_CONCURRENT=3 RSYNC_MAX_CONCURRENT=3