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:
co-authored by
Claude Sonnet 4.6
parent
9191a54637
commit
fb0530deba
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
;;
|
||||
```
|
||||
@@ -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) |
|
||||
Executable
+108
@@ -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
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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} · ${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 </span>${net.local_ip}</div>` : '',
|
||||
@@ -868,8 +890,8 @@ function vvPollMonitor() {
|
||||
<div>
|
||||
<div style="font-size:12px;color:#888;margin-bottom:4px;">${net.iface} · ${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>';
|
||||
}
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
@@ -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>';
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user