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
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);
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;
}
+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);
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;
$remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
$remoteConf = rtrim($sm[1] ?? '/mnt/user/appdata/Varaverk', '/') . '/Configurations';
$remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
// SCP master.conf from HOST1
$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; }
}
$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);
if (!$data) {
@@ -594,22 +604,17 @@ function vv_remote_hosts_stats(): array {
continue;
}
$os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? [];
$metrics = $data['metrics'] ?? [];
$mCpu = $metrics['cpu'] ?? [];
$mMem = $metrics['memory'] ?? [];
$arr = $data['array'] ?? [];
$os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? [];
$mMem = $data['metrics']['memory'] ?? [];
$cpuLoad = round((float)($mCpu['percentTotal'] ?? 0), 1);
$memPct = round((float)($mMem['percentTotal'] ?? 0));
// Also compute from raw bytes as cross-check when percentTotal is missing
$memPct = round((float)($mMem['percentTotal'] ?? 0));
if ($memPct === 0) {
$totalBytes = (float)($mMem['total'] ?? 0);
$availBytes = (float)($mMem['available'] ?? 0);
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
}
$memTotalGb = isset($mMem['total']) ? round((float)$mMem['total'] / (1024 ** 3), 1) : 0;
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
@@ -623,19 +628,20 @@ function vv_remote_hosts_stats(): array {
$uptime = $uptimeRaw ?: '—';
}
$entry = [
$nodeMetrics = vv_api_node_metrics($data);
$entry = array_merge([
'available' => true,
'host_id' => $id,
'hostname' => $os['hostname'] ?? $vars[$id],
'version' => $os['release'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'cpu_load' => $cpuLoad,
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'mem_total_gb' => $memTotalGb,
'mem_used_pct' => $memPct,
'array_state' => $arr['state'] ?? 'UNKNOWN',
];
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
], $nodeMetrics);
file_put_contents($cacheFile, json_encode($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; }
}
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 = [];
$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.
+4 -2
View File
@@ -5,12 +5,14 @@
define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.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('DATA_DIR', SCRIPTS_DIR . '/data');
define('STATE_DIR', SCRIPTS_DIR . '/State_Files');
define('LOG_DIR', '/var/log/varaverk');
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.
function vv_setup_state_read(): array {
+2 -1
View File
@@ -156,7 +156,7 @@ function vv_watchdog_summary(): array {
}
// 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) ?: '';
$cutoff = time() - 86400;
$restarts = [];
@@ -297,6 +297,7 @@ function vv_scripts_status(): array {
$ts = (int)($stat['end'] ?? $stat['start'] ?? @filemtime($statFile) ?: 0);
$scripts[] = [
'id' => $id,
'name' => $name,
'last_ts' => $ts,
'status' => $status,
+162 -28
View File
@@ -3,23 +3,63 @@
require_once __DIR__ . '/config.php';
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 ────────────────────────────────────────────────────────────────────
function vv_pt_config(): array {
$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 [
'enabled' => ($v['PARTNERSHIP_ENABLED'] ?? 'false') === 'true',
'owner_host' => $v['PARTNERSHIP_OWNER_HOST'] ?? '',
'sync_min' => (int)($v['PARTNERSHIP_SYNC_INTERVAL'] ?? 15),
'grace_hours' => (int)($v['PARTNERSHIP_GRACE_HOURS'] ?? 6),
'offline_threshold' => (int)($v['PARTNERSHIP_OFFLINE_THRESHOLD'] ?? 30),
'offline_days' => $offlineDays,
'remove_tailscale' => ($v['PARTNERSHIP_REMOVE_TAILSCALE'] ?? 'true') === 'true',
'folderview3' => ($v['PARTNERSHIP_FOLDERVIEW3'] ?? 'false') === 'true',
'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 ─────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────────
// 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 {
$ver = '';
if (file_exists('/etc/unraid-version')) {
preg_match('/VERSION="([^"]+)"/', file_get_contents('/etc/unraid-version'), $m);
$ver = $m[1] ?? '';
}
$uptime = 0;
if (file_exists('/proc/uptime')) {
$uptime = (int)explode(' ', file_get_contents('/proc/uptime'))[0];
}
return ['unraid_version' => $ver, 'uptime_sec' => $uptime];
$info = vv_system_info();
$uptimeSec = file_exists('/proc/uptime')
? (int)explode(' ', file_get_contents('/proc/uptime'))[0] : 0;
return [
'unraid_version' => $info['version'] ?? '',
'uptime_sec' => $uptimeSec,
'load_avg' => isset($info['load_avg']) ? $info['load_avg'][0] : null,
'containers' => count(vv_docker_containers()),
];
}
// 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 {
$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 = '';
preg_match('/VERSION="([^"]+)"/', $out, $m);
if ($m) $ver = $m[1];
preg_match('/VERSION="([^"]+)"/', $out, $m); if ($m) $ver = $m[1];
$uptime = 0;
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 ─────────────────────────────────────────────────────────────
@@ -110,6 +166,9 @@ function vv_pt_nodes(): array {
$ownerSlot = strtolower($vars['PARTNERSHIP_OWNER_HOST'] ?? '');
$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
$myId = strtoupper($currentHost);
$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)
$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[] = [
'slot' => $slot,
'id' => $nodeIdUpper,
'hostname' => $hostname,
'is_me' => $isMe,
'is_owner' => $isOwner,
'ts_online' => $ts['online'],
'ts_active' => $ts['active'],
'ts_ip' => $ts['ip'],
'fallback' => $fbState,
'partnership' => $ptDb,
'system' => $system,
'onboard_phase' => $onboardPhase,
'key_ready' => $keyReady,
'local_done' => $localDone,
'slot' => $slot,
'id' => $nodeIdUpper,
'hostname' => $hostname,
'is_me' => $isMe,
'is_owner' => $isOwner,
'ts_online' => $ts['online'],
'ts_active' => $ts['active'],
'ts_ip' => $ts['ip'],
'fallback' => $fbState,
'partnership' => $ptDb,
'system' => $system,
'onboard_phase' => $onboardPhase,
'key_ready' => $keyReady,
'local_done' => $localDone,
'api_key_set' => $apiKeySet,
'api_key_preview' => $apiKeyPreview,
'metrics' => $metrics,
];
}
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 ───────────────────────────────────────────────────────────────
function vv_partnership_all(): array {
return [
'config' => vv_pt_config(),
'nodes' => vv_pt_nodes(),
'sync' => vv_pt_sync(),
'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) ─────────────────
// 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.
+1 -1
View File
@@ -313,7 +313,7 @@ function vv_wd_all(): array {
$currentHost = vv_detect_host();
$tsPeers = vv_pt_ts_peers();
$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
$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 name = s.name.length > 24 ? s.name.slice(0, 23) + '…' : s.name;
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:#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>
@@ -485,6 +488,12 @@ function vvScriptsFilterSet(type) {
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) ─────
function vvTempColor(tempC, transport) {
@@ -575,6 +584,16 @@ function vvPollMonitor() {
const _runningVMs = (d.vms?.vms ?? []).filter(v => v.state === 'running').length;
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 =
`<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;">
<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: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;">
<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;">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;">Version</span> <span style="color:#3a3a3a;">${ver}</span>
</div>`;
@@ -856,6 +876,8 @@ function vvPollMonitor() {
const maxSeen = Math.max(...vvNetRxHistory, ...vvNetTxHistory, 1);
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 = [
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 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;">
<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:#ff9800;font-size:9px;margin-right:4px;">━ OUT (TX)</span><span style="color:#ff9800;">${vvFmtBps(tx)}</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 style="color:#444;font-size:9px;font-weight:400;margin-left:4px;">peak ${vvFmtBps(peakTx)}</span></span>
</div>
</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>';
}
// ── CPU title ────────────────────────────────────────────────────────────
// ── CPU title (core count + temp to its right) ────────────────────────────
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 ─────────────────────────────────────────────────────────
const ups = d.ups ?? {};
@@ -1072,13 +1100,15 @@ function vvPollMonitor() {
const sshdOk = stab.sshd_ok ?? true;
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;">/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;">RAM free</span><span style="color:${ramColor};">${ramFree}GB</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;">${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>
@@ -1208,6 +1238,22 @@ function vvPollMonitor() {
const procCount = gpuProcs.length;
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 =
// header row: name + process count pill
`<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:#666;margin-left:12px;">Power</span>
<span style="color:#aaa;font-weight:bold;">${powerStr}</span>
</div>`;
</div>` + gpuProcHtml;
} else {
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); }
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>
<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>
<!-- 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 -->
<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>
<!-- 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 -->
<div class="vv-card" id="vv-pt-actions-card">
<h3>Actions</h3>
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</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>
// ── Toggle states — persist across 10s poll re-renders ────────────────────────
const _vvOnboarding = {}; // hostId → true when onboard steps are expanded
@@ -96,6 +147,33 @@ function vvPtHideDeleteKeys(hostId) {
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) {
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;
@@ -175,6 +253,176 @@ function vvPtOffboard(btn) {
.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 ─────────────────────────────────────────────────────────
(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>`;
}
// ── 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 ────────────────────────────────────────────────────────────────
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>` :
`<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 = '';
body += phaseHtml;
body += apiKeyHtml;
body += metricsHtml;
// Network
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 hasPartner = remotes.some(n => n.hostname);
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 = '';
@@ -365,13 +745,18 @@ function _renderActions(nodes, cfg) {
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;">
<div>
<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>
</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>`;
const isDeleting = !!_vvDeleteKeys[remote.id];
@@ -516,10 +901,18 @@ function _renderActions(nodes, cfg) {
</button></div>`;
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;">
<span style="font-size:11px;color:#555;">Transfer ownership run manually:</span>
<div class="vv-pt-transfer-note" style="margin-top:4px;padding:6px 10px;background:#111;border-radius:4px;color:#555;">
bash Partnership/partnership_transfer.sh --confirm=i-understand-this-transfers-ownership
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="font-size:11px;color:#555;">Transfer ownership to the mirror promotes it to owner.</span>
<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>`;
}
@@ -536,6 +929,12 @@ function _render(data) {
// Config bar
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
const cols = nodes.length <= 2 ? nodes.length : nodes.length <= 4 ? 2 : 3;
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
$vv_setup_conf = preg_match('/^[\w.]+\.conf$/', $_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_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) {
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);
let html = '<div class="vv-errors-list">';
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, ' ');
html += '<div class="vv-err-row">'
+ '<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-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 class="vv-err-line">' + vvEscHtml(e.line) + '</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
}).then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Created — loading…'; status.className = 'ok';
setTimeout(() => { window.location.href = '?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>); }, 600);
status.textContent = '✓ Created'; status.className = 'ok';
vvShowStep2('?tab=scheduler&vv_setup=' + encodeURIComponent(<?= json_encode($myHostId . '.conf') ?>));
} else {
btn.disabled = false; btn.textContent = 'Create <?= htmlspecialchars($myHostId) ?>.conf and continue →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
@@ -151,8 +151,8 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
}).then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Configuration pulled — loading…'; status.className = 'ok';
setTimeout(() => { window.location.href = d.redirect ?? '?tab=scheduler'; }, 600);
status.textContent = '✓ Configuration pulled'; status.className = 'ok';
vvShowStep2(d.redirect ?? '?tab=scheduler');
} else {
btn.disabled = false; btn.textContent = 'Pull configuration from HOST1 →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
@@ -244,8 +244,8 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params
}).then(r => r.json()).then(d => {
if (d.ok) {
status.textContent = '✓ Saved — loading…'; status.className = 'ok';
setTimeout(() => { window.location.href = d.redirect ?? '?tab=scheduler&vv_setup=master.conf'; }, 600);
status.textContent = '✓ Saved'; status.className = 'ok';
vvShowStep2(d.redirect ?? '?tab=scheduler&vv_setup=master.conf');
} else {
btn.disabled = false; btn.textContent = 'Save and continue →';
status.textContent = '✗ ' + (d.error ?? 'Error'); status.className = 'err';
@@ -255,4 +255,58 @@ $isConfOnlyFlow = !empty($masterHost1) && $confMissing;
</script>
<?php endif; ?>
<!-- Step 2: API key — shown after any wizard flow completes -->
<div id="vv-setup-step2" style="display:none;">
<hr class="vv-setup-divider">
<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">Step 2 of 2 — Unraid API Key</div>
<div class="vv-setup-info-box">
Varaverk uses the local Unraid API to display live stats on the Monitor tab.
Creates a <strong>Varaverk</strong> key via <code style="color:#555;">unraid-api</code> and writes it to your host conf.
</div>
<div style="display:flex;gap:10px;align-items:center;">
<button id="vv-key-btn2" onclick="vvCreateApiKeyWizard(this)"
style="flex:1;padding:10px 0;background:#1a3a1a;border:1px solid #2e6b2e;color:#6fcf97;
font-family:monospace;font-size:13px;border-radius:3px;cursor:pointer;">
Create API Key
</button>
<a id="vv-skip-link" href="#" onclick="vvWizardContinue(event)"
style="font-size:11px;color:#444;text-decoration:none;white-space:nowrap;">Skip →</a>
</div>
<div id="vv-key-status2" style="margin-top:8px;font-size:12px;min-height:16px;"></div>
</div>
<script>
let _vvWizardNext = '';
function vvShowStep2(redirect) {
_vvWizardNext = redirect;
document.getElementById('vv-setup-step2').style.display = 'block';
}
function vvWizardContinue(e) {
if (e) e.preventDefault();
window.location.href = _vvWizardNext || '?tab=scheduler';
}
function vvCreateApiKeyWizard(btn) {
const status = document.getElementById('vv-key-status2');
btn.disabled = true; btn.textContent = '⟳ Creating…';
fetch('/plugins/varaverk/api/create_api_key.php?_=' + Date.now())
.then(r => r.json())
.then(d => {
if (d.ok) {
status.textContent = '✓ Key created — ' + d.key_preview; status.style.color = '#4a8';
btn.textContent = 'Continue →'; btn.disabled = false;
btn.onclick = vvWizardContinue;
const skip = document.getElementById('vv-skip-link');
if (skip) skip.style.display = 'none';
} else {
status.textContent = '✗ ' + (d.error ?? 'Failed'); status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
}
})
.catch(e => {
status.textContent = '✗ ' + e; status.style.color = '#a44';
btn.disabled = false; btn.textContent = 'Retry';
});
}
</script>
</div>
+141 -131
View File
@@ -2,18 +2,26 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "varaverk">
<!ENTITY author "gmer4lfe">
<!ENTITY version "2026.05.30">
<!ENTITY version "2026.05.31">
<!ENTITY sha256 "932468c8a114134dea3062f2810a7ca5a7b1168e1e78d0195c41c61b86db7728">
<!ENTITY launch "varaverk/monitor">
<!ENTITY github "https://github.com/FailedProxy/Varaverk">
<!ENTITY branch "main">
<!ENTITY cfgdir "/boot/config/plugins/varaverk">
<!ENTITY plugdir "/usr/local/emhttp/plugins/varaverk">
<!ENTITY pkg "varaverk-&version;-noarch-1.txz">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;"
support="https://github.com/FailedProxy/Varaverk/issues"
icon="/plugins/varaverk/icons/varaverk.png">
<CHANGES>
###2026.05.31
- Packaged release: web files now ship as a .txz that Unraid reinstalls to RAM on every boot
- Survives reboots with zero manual steps (no symlink, no go script) — fixes plugin vanishing after OS upgrades
- Scripts are git-cloned to appdata on first install; web files stay on flash (~200KB)
- Updates handled in-UI (git pull); the plugin no longer pulls on every boot
###2026.05.30
- First-run setup wizard: auto-detects hostname, creates master.conf + host conf from templates
- Scheduler setup mode: after wizard, master.conf and host conf open sequentially with forced save flow
@@ -33,143 +41,153 @@
</CHANGES>
<!--
── Install / Update ────────────────────────────────────────────────────────────
Runs on first install and on every plugin update.
Clones the repo on fresh install; pulls latest on update.
Array must be started — appdata must be available.
── 1. Web files package (runs on every boot) ──────────────────────────────────
unRAID runs `plugin install` on every .plg at boot, BEFORE the array mounts.
The .txz lives on flash, so this installs the PHP/JS UI to RAM in time — and the
disks_mounted event hooks inside it are then present when the array starts, so
cron is rebuilt automatically. No Method attr = treated as "install" = every boot.
Download is skipped when the .txz is already on flash with a matching SHA256, so
this also works offline / for a local install (copy the .txz into &cfgdir;).
────────────────────────────────────────────────────────────────────────────────
-->
<FILE Name="&cfgdir;/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>
<![CDATA[
#!/bin/bash
PLUGIN="varaverk"
GITHUB="https://github.com/FailedProxy/Varaverk"
BRANCH="main"
CFG_DIR="/boot/config/plugins/$PLUGIN"
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"
mkdir -p "$CFG_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; }
log "=== Varaverk install/update ==="
# 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}"
# Scripts live in the plugin dir on flash — no array needed.
SCRIPTS_DIR="$CFG_DIR"
CONF_DIR="$SCRIPTS_DIR/Configurations"
# Array must be started — appdata must be available
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
# Seed varaverk.cfg with defaults (SCRIPTS_DIR + Gitea settings) if not present.
if [[ ! -f "$CFG_FILE" ]]; then
printf 'SCRIPTS_DIR="%s"\n' "$SCRIPTS_DIR" > "$CFG_FILE"
log "Created varaverk.cfg (SCRIPTS_DIR=$SCRIPTS_DIR)"
cat > "$CFG_FILE" <<'CFGEOF'
SCRIPTS_DIR="/boot/config/plugins/varaverk"
GITEA_CONTAINER="Gitea"
GITEA_REPO_PATH="FailedProxy/Varaverk.git"
GITEA_SSH_KEY="/root/.ssh/unraid_gitea"
SSH_PORT="221"
CFGEOF
log "seeded varaverk.cfg"
fi
# Create Configurations dir if git clone didn't
mkdir -p "$CONF_DIR"
# Read Gitea settings from varaverk.cfg (allows override without editing .plg).
_read_cfg() { grep -oP "(?<=^${1}=\")[^\"]*" "$CFG_FILE" 2>/dev/null || echo "${2}"; }
GITEA_CONTAINER=$(_read_cfg GITEA_CONTAINER "Gitea")
GITEA_REPO_PATH=$(_read_cfg GITEA_REPO_PATH "FailedProxy/Varaverk.git")
GITEA_SSH_KEY=$(_read_cfg GITEA_SSH_KEY "/root/.ssh/unraid_gitea")
SSH_PORT=$(_read_cfg SSH_PORT "221")
# Bootstrap master.conf from template on first install
if [[ ! -f "$CONF_DIR/master.conf" ]]; then
if [[ -f "$SCRIPTS_DIR/Configurations/master.conf.template" ]]; then
cp "$SCRIPTS_DIR/Configurations/master.conf.template" "$CONF_DIR/master.conf"
log "Created master.conf from template — fill in HOST1 and HOST2 to continue"
else
log "WARNING: master.conf.template not found — master.conf not created"
# Clone on first install only; never auto-pull (updates via the UI git pull).
if [[ ! -d "$SCRIPTS_DIR/.git" ]]; then
log "initialising repo in $SCRIPTS_DIR ($BRANCH)..."
# Locate Gitea: local container → local IP; else Tailscale; else fall back to GitHub.
GITEA_IP=""
if command -v docker >/dev/null 2>&1 && \
docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${GITEA_CONTAINER}$"; then
GITEA_IP=$(hostname -I | awk '{print $1}')
log "Gitea running locally — using $GITEA_IP"
elif command -v tailscale >/dev/null 2>&1; then
# Try each known peer until we find one hosting Gitea
while IFS= read -r peer_ip; do
if ssh -i "$GITEA_SSH_KEY" -p "$SSH_PORT" \
-o ConnectTimeout=3 -o StrictHostKeyChecking=no \
-o BatchMode=yes "git@${peer_ip}" info 2>/dev/null | grep -q "varaverk\|Gitea\|gitea"; then
GITEA_IP="$peer_ip"
log "Gitea found on Tailscale peer $GITEA_IP"
break
fi
done < <(tailscale status --json 2>/dev/null | \
python3 -c "import json,sys; d=json.load(sys.stdin); \
[print(v['TailscaleIPs'][0]) for v in d.get('Peer',{}).values() \
if v.get('TailscaleIPs')]" 2>/dev/null)
fi
# init-in-place — git clone would fail because the dir already has files.
git -C "$SCRIPTS_DIR" init >> "$LOG" 2>&1
CLONED=false
if [[ -n "$GITEA_IP" && -f "$GITEA_SSH_KEY" ]]; then
GITEA_URL="ssh://git@${GITEA_IP}:${SSH_PORT}/${GITEA_REPO_PATH}"
log "trying Gitea: $GITEA_URL"
git -C "$SCRIPTS_DIR" remote add origin "$GITEA_URL" >> "$LOG" 2>&1
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT -o StrictHostKeyChecking=no" \
GIT_TERMINAL_PROMPT=0 \
git -C "$SCRIPTS_DIR" fetch --depth=1 origin "$BRANCH" >> "$LOG" 2>&1; then
git -C "$SCRIPTS_DIR" reset --hard FETCH_HEAD >> "$LOG" 2>&1
git -C "$SCRIPTS_DIR" branch -M "$BRANCH" >> "$LOG" 2>&1
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
log "Install complete — open Varaverk in Unraid to complete setup"
log "→ Plugins → Varaverk (or navigate to Settings → Utilities → Varaverk)"
# Seed master.conf from template if absent.
mkdir -p "$CONF_DIR"
if [[ ! -f "$CONF_DIR/master.conf" && -f "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" ]]; then
cp "$SCRIPTS_DIR/Deployment/conf_templates/master.conf" "$CONF_DIR/master.conf"
log "seeded master.conf from template"
fi
log "install step complete"
]]>
</INLINE>
</FILE>
<!--
── Boot (every boot) ───────────────────────────────────────────────────────────
Fast idempotent check — recreates the web symlink if it disappeared.
No git operations. Safe to run before the array is fully started.
── 3. Remove ───────────────────────────────────────────────────────────────────
Stops background scripts, removes cron, the installed package, and flash config.
Scripts/conf in appdata are left intact (delete manually for a full wipe).
────────────────────────────────────────────────────────────────────────────────
-->
<FILE Name="&cfgdir;/boot.sh" Run="/bin/bash">
<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">
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
#!/bin/bash
@@ -178,44 +196,36 @@ CFG_DIR="/boot/config/plugins/$PLUGIN"
CFG_FILE="$CFG_DIR/varaverk.cfg"
CRON_FILE="$CFG_DIR/varaverk.cron"
WEB_DIR="/usr/local/emhttp/plugins/$PLUGIN"
log() { echo "[Varaverk remove] $*"; }
# Read SCRIPTS_DIR so we can locate running scripts
if [[ -f "$CFG_FILE" ]]; then
_sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
fi
SCRIPTS_DIR="${_sd:-/mnt/user/appdata/Varaverk}"
[[ -f "$CFG_FILE" ]] && _sd=$(grep -oP '(?<=SCRIPTS_DIR=")[^"]+' "$CFG_FILE" 2>/dev/null)
SCRIPTS_DIR="${_sd:-$CFG_DIR}"
# ── Stop continuous background scripts ───────────────────────────────────────
# fallback.sh manages its own lock — use --stop for clean shutdown
# Stop continuous background scripts.
if [[ -f "$SCRIPTS_DIR/Fallback/fallback.sh" ]]; then
bash "$SCRIPTS_DIR/Fallback/fallback.sh" --stop 2>/dev/null && \
log "fallback.sh stopped" || true
bash "$SCRIPTS_DIR/Fallback/fallback.sh" --stop 2>/dev/null && log "fallback.sh stopped" || true
fi
# Kill any remaining Varaverk background processes (watchdog orchestrator, etc.)
pkill -f "run_job.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
rm -f "$CRON_FILE"
/usr/local/sbin/update_cron 2>/dev/null || true
log "Cron entries removed"
log "cron removed"
fi
# Remove legacy direct cron file if it exists
rm -f /etc/cron.d/varaverk
# ── Remove web symlink ────────────────────────────────────────────────────────
[[ -L "$WEB_DIR" ]] && rm -f "$WEB_DIR" && log "Web symlink removed"
# Remove the installed package (and its RAM files).
removepkg "$PLUGIN" 2>/dev/null || true
[[ -L "$WEB_DIR" ]] && rm -f "$WEB_DIR"
[[ -d "$WEB_DIR" ]] && rm -rf "$WEB_DIR"
log "web files removed"
# ── Remove plugin config from flash ──────────────────────────────────────────
# Remove flash config (incl. cached .txz).
rm -rf "$CFG_DIR"
log "Plugin config removed from flash"
log "Done. Scripts and conf in $SCRIPTS_DIR are preserved."
log "To fully remove: delete $SCRIPTS_DIR manually."
log "flash config removed"
log "done — scripts/conf in $SCRIPTS_DIR preserved (delete manually for full wipe)"
]]>
</INLINE>
</FILE>