From bac0f7c5357f3733fc275f1747e69c006c4b9e33 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sat, 6 Jun 2026 15:39:34 -0400 Subject: [PATCH] Auth page: pure-PHP Authelia ACL parser, NPM-sourced certs tab, watchdog JS fixes, rsync settings layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace python3/PyYAML Authelia ACL parser with pure PHP (no deps available on Unraid) - Cert tab now pulls live from NPM API instead of cert_monitor.sh — auto-discovers all managed certs sorted by urgency - Watchdog page: add missing GB constant and _fmtBytes/_relTime functions that were causing silent render failure - Rsync settings card: pin to far-right 3 columns (grid-column:6/-1), toggle grid narrowed to 2 columns - Add CLAUDE.md project context file on /boot for session persistence across reboots - claude_startup.sh: symlink CLAUDE.md into /root on array start --- CLAUDE.md | 151 +++++++++++++++++++++ Plugin/unraid/api/cert.php | 38 ++++++ Plugin/unraid/include/auth.php | 223 ++++++++++++++++++++++++------- Plugin/unraid/pages/auth.php | 61 ++++----- Plugin/unraid/pages/rsync.php | 26 +--- Plugin/unraid/pages/watchdog.php | 28 ++-- Tools/claude_startup.sh | 11 ++ 7 files changed, 425 insertions(+), 113 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aa37211 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,151 @@ +# Varaverk — Claude Code Context + +## Working Rules (read first) + +- **Workspace is always** `/boot/config/plugins/varaverk` — every edit goes here. +- **Never touch** `/mnt/user/Important Shit/Git/Development/Varaverk` — stale dev folder, ignore it. +- **No Co-Authored-By** in commit messages unless explicitly asked. +- **No comments** unless the WHY is genuinely non-obvious. +- The `.plg` symlinks the installed plugin location directly to this workspace — one copy, no drift. + +--- + +## Project: What Varaverk Is + +Self-healing, self-maintaining, mutually-redundant two-server Unraid home media ecosystem. +One codebase runs on both servers. No primary/standby — both run independently and cover each other. + +**HOST1 — unRAID-Gmer4Lfe** (`gmer4lfe@gmail.com`) +- Hardware: Threadripper 1950X, 128 GB RAM, ZFS cache pools +- Domain: Gmer4Lfe.com +- Runs: full arr stack (Sonarr/Radarr/Lidarr), auth stack (source of truth), Emby primary + +**HOST2 — unRAID-Jayred365** +- Hardware: Intel i5 10th gen, 64 GB RAM +- Domain: Gmer4Lfe.us +- Status: being rebuilt — most host2.conf sections scaffolded, not yet fully online + +Networking between hosts: Tailscale mesh. No hardcoded IPs — hostnames resolve via Tailscale. + +--- + +## Configuration System (three-file model) + +Every script sources all three at startup: + +``` +master.conf ← shared: thresholds, toggles, profiles, orchestrator job lists +host1.conf ← HOST1 credentials, shares, container names, keys +host2.conf ← HOST2 credentials, shares, container names, keys +``` + +Sparse checkout (git) means each server only pulls its own `host*.conf`. +HOST1 never sees HOST2 credentials and vice versa. + +**Rule:** thresholds/toggles → `master.conf`; credentials/paths/container names → `host*.conf`. + +`detect_hosts()` in `common.sh` matches `$(hostname)` against `HOST1`/`HOST2` in `master.conf` +and sets `MY_ID` / `REMOTE_ID` for the rest of the script. + +--- + +## Platform Adapter Layer + +`Plugin/unraid/adapter.sh` isolates all OS-specific calls. +Scripts never branch on OS directly — always call adapter functions. +This is intentional architecture — don't bypass it. + +--- + +## Key Paths + +| Path | Purpose | +|------|---------| +| `master.conf` | Shared config — all thresholds, toggles, profiles | +| `host1.conf` / `host2.conf` | Per-host credentials, shares, container lists | +| `common.sh` | Shared functions — `detect_hosts()`, `log()`, `notify()`, etc. | +| `load_config.sh` | Sources all three conf files + common.sh | +| `State_Files/` | Runtime state (watchdogs, fallback, transcode) — survives reboots | +| `data/` | Historical logs and stats | +| `Plugin/unraid/` | Unraid WebGUI plugin (PHP pages, API endpoints, adapter) | +| `Orchestrators/` | Top-level schedulers (array_started, daily, weekly, watchdog) | +| `Watchdogs/` | docker_watchdog, system_watchdog, resource_watchdog, stability | +| `Fallback/` | Mutual container failover logic | +| `Rsync/` | rsync.sh + profile system | +| `Media/` | Arr cleanup, discovery, permissions, play state sync | +| `Tools/` | Manual one-off tools including `claude_startup.sh` | + +--- + +## Orchestrator Schedule + +| When | What | +|------|------| +| Array start | `Orchestrators/array_started.sh` → runs `ARRAY_START_SCRIPTS` | +| Every minute | `watchdog_orchestrator.sh` → resource → docker → system → stability watchdogs | +| Every 30 min | `critical_sync_maintenance.sh` → downloaders_reset, play_state_sync, critical rsync | +| Every 4 hours | `intermediate_sync_maintenance.sh` → arr_sync, arrs_failed_stalled_recovery | +| Daily 1am | `daily_sync_maintenance.sh` → git pull, permissions, cleaners, arr cleanup, docker updates | +| Sunday 2:30am | `weekly_sync_maintenance.sh` → full Emby + Critical-Data sync, weekly restarts | +| Sunday 3am+ | `monthly_maintenance.sh` (self-gated on 30-day uptime) → ZFS scrub, SMART tests | +| Sunday 7am | `sunday_morning_coffee_report.sh` → ZFS, SMART, certs, backup verify, bandwidth, Emby report | + +--- + +## Rsync Toggle State (current) + +```bash +RSYNC_ENABLED=true +CRITICAL_RSYNC_ENABLED=true +INTERMEDIATE_RSYNC_ENABLED=true +DAILY_RSYNC_ENABLED=false # HOST2 rebuild in progress — re-enable when ready +WEEKLY_RSYNC_ENABLED=true +FALLBACK_RSYNC_ENABLED=true +``` + +--- + +## Fallback System + +`fallback.sh` runs continuously from array start. +States: `NORMAL | FALLBACK | NO_INTERNET | DARK` + +DDNS rules are absolute: +- Internet loss → stop own DDNS immediately +- Failover → start remote's DDNS as Tier 1 first +- Handback → stop remote DDNS → rsync → start containers → start local DDNS last + +Tier delays before activating higher tiers are in `host*.conf` (`HOST1_TIER*_DELAY`, `HOST2_TIER*_DELAY`). + +--- + +## Port Notes + +- **NPM admin API (`HOST1_NPM_URL`)** — port **7818**. Port 81 is the partnership WebUI port (`HOST1_PARTNERSHIP_AUTH_WEBUIS`), not the API. Easy to confuse. +- **HOST1_NETWORK_WATCHDOG_NPM_URL** — external HTTPS domain, completely separate from the admin API. + +## Known Gaps / Active Work + +- HOST2 NPM/lldap credentials (`HOST2_NPM_USER`, `HOST2_NPM_PASS`, `HOST2_LLDAP_PASS`) are empty in `host2.conf` — fill in when HOST2 is back online. +- `PARTNERSHIP_ENABLED=false` — not yet active. +- `FALLBACK_ENABLED=true` — fallback is running. +- `DAILY_RSYNC_ENABLED=false` — paused during HOST2 rebuild. + +--- + +## Claude Code Persistence on Unraid + +`/root` is a RAM filesystem — wiped on every reboot. +`Tools/claude_startup.sh` runs at array start (via `ARRAY_START_SCRIPTS`) and: +- Symlinks `/root/.claude` → `/mnt/user/appdata/claude-code/.claude` +- Symlinks `/root/.local/share/claude` → `/mnt/user/appdata/claude-code/local/share/claude` +- Symlinks `/root/CLAUDE.md` → `/boot/config/plugins/varaverk/CLAUDE.md` (this file) + +This file lives on `/boot` (USB flash) and is always available regardless of array state. + +--- + +## Commit Style + +Plain, concise messages. No Co-Authored-By trailers. No bullet-point summaries in the body. +One sentence on the why, not the what. diff --git a/Plugin/unraid/api/cert.php b/Plugin/unraid/api/cert.php index 0bcba59..3bb1c78 100644 --- a/Plugin/unraid/api/cert.php +++ b/Plugin/unraid/api/cert.php @@ -1,6 +1,7 @@ false, 'error' => $raw['_err'] ?? 'NPM request failed'])); + + $master = vv_read_conf_raw('master.conf'); + preg_match('/^\s*CERT_WARN_DAYS\s*=\s*(\d+)/m', $master, $w); + preg_match('/^\s*CERT_CRIT_DAYS\s*=\s*(\d+)/m', $master, $c); + $warn = (int)($w[1] ?? 30); + $crit = (int)($c[1] ?? 7); + $now = time(); + + $certs = []; + foreach ($raw as $cert) { + $exp = !empty($cert['expires_on']) ? strtotime($cert['expires_on']) : false; + $days = $exp !== false ? (int)(($exp - $now) / 86400) : null; + $status = $days === null ? 'UNKN' + : ($days < 0 ? 'CRIT' + : ($days <= $crit ? 'CRIT' + : ($days <= $warn ? 'WARN' : 'OK'))); + $certs[] = [ + 'id' => $cert['id'], + 'nice_name' => $cert['nice_name'] ?? implode(', ', $cert['domain_names'] ?? []), + 'domain_names'=> $cert['domain_names'] ?? [], + 'provider' => $cert['provider'] ?? 'unknown', + 'expires' => !empty($cert['expires_on']) ? substr($cert['expires_on'], 0, 10) : '', + 'days' => $days, + 'status' => $status, + ]; + } + usort($certs, fn($a, $b) => ($a['days'] ?? PHP_INT_MAX) <=> ($b['days'] ?? PHP_INT_MAX)); + + echo json_encode(['ok' => true, 'certs' => $certs, 'warn_days' => $warn, 'crit_days' => $crit]); + exit; +} + // ── Read configured domains (without running checks) ───────────────────────── if ($action === 'domains') { $hostId = vv_detect_host(); diff --git a/Plugin/unraid/include/auth.php b/Plugin/unraid/include/auth.php index f43d5b0..5f7b1ca 100644 --- a/Plugin/unraid/include/auth.php +++ b/Plugin/unraid/include/auth.php @@ -31,7 +31,6 @@ function vv_npm_token(): string { $resp = vv_npm_raw('POST', '/api/tokens', [ 'identity' => $conf['npm_user'], 'secret' => $conf['npm_pass'], - 'expiry' => '1d', ], '', $conf); $token = $resp['token'] ?? ''; if ($token) { @@ -146,15 +145,15 @@ function vv_lldap_gql(string $query, array $variables = []): array { } function vv_lldap_list_users(): array { - $r = vv_lldap_gql('query { listUsers { id displayName email creationDate groups { id displayName } } }'); + $r = vv_lldap_gql('query { users { id displayName email creationDate groups { id displayName } } }'); if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed']; - return ['ok' => true, 'users' => $r['data']['listUsers'] ?? []]; + return ['ok' => true, 'users' => $r['data']['users'] ?? []]; } function vv_lldap_list_groups(): array { - $r = vv_lldap_gql('query { listGroups { id displayName users { id displayName } } }'); + $r = vv_lldap_gql('query { groups { id displayName users { id displayName } } }'); if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed']; - return ['ok' => true, 'groups' => $r['data']['listGroups'] ?? []]; + return ['ok' => true, 'groups' => $r['data']['groups'] ?? []]; } function vv_lldap_create_user(string $id, string $email, string $displayName, string $password): array { @@ -186,12 +185,24 @@ function vv_lldap_delete_user(string $id): array { } function vv_lldap_set_password(string $userId, string $password): array { - $r = vv_lldap_gql( - 'mutation ChangePassword($userId: String!, $password: String!) { changeUserPassword(userId: $userId, password: $password) }', - ['userId' => $userId, 'password' => $password] - ); - if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Password change failed']; - return ['ok' => true]; + $conf = vv_auth_conf(); + $token = vv_lldap_token(); + if (!$token) return ['ok' => false, 'error' => 'lldap auth failed']; + + $ch = curl_init($conf['lldap_url'] . '/auth/admin/resetPassword'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['userId' => $userId, 'password' => $password]), + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token], + ]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 200 && $code < 300) return ['ok' => true]; + $err = json_decode($body ?: '{}', true)['message'] ?? "HTTP $code"; + return ['ok' => false, 'error' => $err]; } function vv_lldap_create_group(string $name): array { @@ -237,19 +248,105 @@ function vv_authelia_read_rules(): array { $file = $conf['authelia_config']; if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file]; - $py = "import yaml,json,sys\n" - . "d=yaml.safe_load(open(sys.argv[1]))\n" - . "ac=d.get('access_control',{})\n" - . "print(json.dumps({'default_policy':ac.get('default_policy','deny'),'rules':ac.get('rules',[])}))\n"; - $tmp = '/tmp/vv_auth_rd_' . getmypid() . '.py'; - file_put_contents($tmp, $py); - $out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' 2>/dev/null'); - @unlink($tmp); + $content = file_get_contents($file); + if ($content === false) return ['ok' => false, 'error' => 'Cannot read config file']; - if (!$out) return ['ok' => false, 'error' => 'Parse failed — python3 with PyYAML required']; - $data = json_decode(trim($out), true); - if (!$data) return ['ok' => false, 'error' => 'Invalid YAML response']; - return ['ok' => true, 'default_policy' => $data['default_policy'], 'rules' => $data['rules']]; + // Extract default_policy (strip inline comments) + $defaultPolicy = 'deny'; + if (preg_match('/^[ \t]+default_policy:[ \t]+([a-z_]+)/m', $content, $m)) + $defaultPolicy = $m[1]; + + // Extract the indented block under access_control: + if (!preg_match('/^access_control:[ \t]*\n((?:[ \t][^\n]*\n?)*)/m', $content, $m)) + return ['ok' => false, 'error' => 'access_control section not found']; + + $acBlock = $m[1]; + + // Extract the indented block under rules: (3+ space indent = rule list items) + if (!preg_match('/^ rules:[ \t]*\n((?:[ \t]{3,}[^\n]*\n?)*)/m', $acBlock, $m)) + return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => []]; + + // Split into individual rule chunks at " - " (indent-4 rule starts) + $chunks = preg_split('/(?=^ - )/m', $m[1]); + $rules = []; + foreach ($chunks as $chunk) { + if (!preg_match('/^ - /', $chunk)) continue; + $rule = vv_authelia_parse_rule_chunk($chunk); + if (!empty($rule)) $rules[] = $rule; + } + + return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => $rules]; +} + +function vv_authelia_parse_rule_chunk(string $chunk): array { + $rule = []; + $field = null; + $list = []; + + $save = function () use (&$rule, &$field, &$list) { + if ($field === null) return; + if (!empty($list)) + $rule[$field] = count($list) === 1 ? $list[0] : $list; + $field = null; + $list = []; + }; + + foreach (explode("\n", $chunk) as $line) { + $raw = rtrim($line); + $trim = trim($raw); + if ($trim === '' || preg_match('/^#+/', $trim)) continue; + $indent = strlen($raw) - strlen(ltrim($raw, ' ')); + + // indent=4, starts with "- " → first field of this rule block + if ($indent === 4 && str_starts_with($trim, '- ')) { + $rest = ltrim(substr($trim, 2)); + if (preg_match('/^([a-z_]+):[ \t]*(.*)$/', $rest, $m)) { + $save(); + $field = $m[1]; + $val = trim($m[2]); + if ($val !== '' && !str_starts_with($val, '#')) { + $rule[$field] = vv_authelia_unquote($val); + $field = null; + } + } + continue; + } + + // indent=6 → named field (scalar or list header) + if ($indent === 6 && preg_match('/^([a-z_]+):[ \t]*(.*)$/', $trim, $m)) { + $save(); + $field = $m[1]; + $val = trim($m[2]); + if ($val !== '' && !str_starts_with($val, '#')) { + $rule[$field] = vv_authelia_unquote($val); + $field = null; + } + continue; + } + + // indent=8, starts with "- " → list item under current field + if ($indent === 8 && str_starts_with($trim, '- ')) { + $list[] = vv_authelia_parse_list_item(trim(substr($trim, 2))); + } + } + $save(); + return $rule; +} + +// Strip surrounding quotes and inline comments from a YAML scalar. +function vv_authelia_unquote(string $val): string { + $val = trim($val); + $val = preg_replace('/\s+#[^"\']*$/', '', $val); // strip trailing comment + if (preg_match('/^(["\'])(.+)\1$/', $val, $m)) return $m[2]; + return $val; +} + +// Parse a YAML list item: flow sequence ['group:name'] or plain/quoted scalar. +function vv_authelia_parse_list_item(string $val): string { + $val = trim($val); + // Flow sequence: ['value'] or ["value"] or [value] + if (preg_match('/^\[[\'""]?([^\]\'""]+)[\'""]?\]$/', $val, $m)) return trim($m[1]); + return vv_authelia_unquote($val); } function vv_authelia_write_rules(array $rules, string $defaultPolicy): array { @@ -257,29 +354,65 @@ function vv_authelia_write_rules(array $rules, string $defaultPolicy): array { $file = $conf['authelia_config']; if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file]; - $acJson = json_encode(['default_policy' => $defaultPolicy, 'rules' => $rules]); - $py = <<<'PYEOF' -import yaml, json, sys, re -config_file = sys.argv[1] -new_ac = json.loads(sys.argv[2]) -with open(config_file, 'r') as f: - content = f.read() -new_block = yaml.dump({'access_control': new_ac}, default_flow_style=False, allow_unicode=True, sort_keys=False) -pattern = r'(?ms)^access_control:.*?(?=^[a-zA-Z#]|\Z)' -if re.search(pattern, content): - content = re.sub(pattern, new_block + '\n', content) -else: - content = content.rstrip('\n') + '\n\n' + new_block + '\n' -with open(config_file, 'w') as f: - f.write(content) -print('ok') -PYEOF; - $tmp = '/tmp/vv_auth_wr_' . getmypid() . '.py'; - file_put_contents($tmp, $py); - $out = shell_exec('python3 ' . escapeshellarg($tmp) . ' ' . escapeshellarg($file) . ' ' . escapeshellarg($acJson) . ' 2>&1'); - @unlink($tmp); + $content = file_get_contents($file); + if ($content === false) return ['ok' => false, 'error' => 'Cannot read config file']; + + // Build the new access_control block + $block = "access_control:\n"; + $block .= " default_policy: $defaultPolicy\n"; + $block .= " rules:\n"; + + // Preferred field output order + $fieldOrder = ['domain', 'policy', 'subject', 'networks', 'resources']; + + foreach ($rules as $rule) { + $keys = array_merge( + array_filter($fieldOrder, fn($k) => array_key_exists($k, $rule)), + array_diff(array_keys($rule), $fieldOrder) + ); + $first = true; + foreach ($keys as $key) { + if (!array_key_exists($key, $rule)) continue; + $val = $rule[$key]; + $prefix = $first ? ' - ' : ' '; + $first = false; + + // domain, subject, resources, networks → always output as list + $isList = in_array($key, ['domain', 'subject', 'resources', 'networks'], true); + if ($isList) { + $items = is_array($val) ? $val : [$val]; + $block .= $prefix . $key . ":\n"; + foreach ($items as $item) { + $out = $key === 'subject' + ? "['" . $item . "']" + : vv_authelia_yaml_scalar((string) $item); + $block .= ' - ' . $out . "\n"; + } + } else { + $block .= $prefix . $key . ': ' . vv_authelia_yaml_scalar((string) $val) . "\n"; + } + } + } + + // Replace existing access_control: block (from its line to next top-level key or EOF) + $pattern = '/^access_control:[ \t]*\n(?:[ \t][^\n]*\n?)*/m'; + $new = preg_match($pattern, $content) + ? preg_replace($pattern, $block, $content, 1) + : rtrim($content) . "\n\n" . $block; + + if ($new === null) return ['ok' => false, 'error' => 'Regex replace failed']; + + $tmp = $file . '.vv.tmp'; + if (file_put_contents($tmp, $new) === false) return ['ok' => false, 'error' => 'Write failed']; + if (!rename($tmp, $file)) { @unlink($tmp); return ['ok' => false, 'error' => 'Atomic rename failed']; } - if (trim($out) !== 'ok') return ['ok' => false, 'error' => 'Write failed: ' . trim($out)]; shell_exec('docker restart ' . escapeshellarg($conf['authelia_container']) . ' >/dev/null 2>&1 &'); return ['ok' => true]; } + +// Quote a YAML scalar value if it contains characters that require quoting. +function vv_authelia_yaml_scalar(string $val): string { + if ($val === '' || preg_match('/[:#\[\]{},|>&*?!%@`\'"]/', $val) || preg_match('/^\s|\s$/', $val)) + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $val) . '"'; + return $val; +} diff --git a/Plugin/unraid/pages/auth.php b/Plugin/unraid/pages/auth.php index 6214d81..f0a0a68 100644 --- a/Plugin/unraid/pages/auth.php +++ b/Plugin/unraid/pages/auth.php @@ -214,14 +214,11 @@ $isOwner = vv_is_owner();
- +
Loading…
-
@@ -954,29 +951,31 @@ function _certRel(ts) { return Math.floor(d/86400) + 'd ago'; } -function _renderCerts(data) { +function _renderNpmCerts(data) { const grid = document.getElementById('vv-au-cert-grid'); const ts = document.getElementById('vv-au-cert-ts'); const cfg = document.getElementById('vv-au-cert-cfg'); const warn = data.warn_days || 30, crit = data.crit_days || 7; + const certs = data.certs || []; - ts.textContent = data.checked_at ? 'Last checked: ' + _certRel(data.checked_at) : 'Not yet checked'; + ts.textContent = certs.length + ' certificate' + (certs.length !== 1 ? 's' : '') + ' · live from NPM'; cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`; - const domains = data.domains || []; - if (!domains.length) { - grid.innerHTML = '
No domains configured — add HOST*_CERT_MONITOR_DOMAINS to host.conf
'; + if (!certs.length) { + grid.innerHTML = '
No certificates found in NPM
'; return; } - grid.innerHTML = domains.map(d => { - const s = d.status || 'UNKN'; - const days = d.days; + grid.innerHTML = certs.map(c => { + const s = c.status || 'UNKN'; + const days = c.days; const col = _certDayColor(days, warn, crit); - const barPct = days != null ? Math.min(Math.round(days/90*100), 100) : 0; - const expStr = d.expires ? 'Expires ' + d.expires : (s === 'UNKN' ? 'Not yet checked' : ''); + const barPct = days != null ? Math.min(Math.round(days / 90 * 100), 100) : 0; + const expStr = c.expires ? 'Expires ' + c.expires : ''; + const extra = (c.domain_names || []).filter(d => d !== c.nice_name).join(', '); return `
-
${_esc(d.domain)}
+
${_esc(c.nice_name)}
+ ${extra ? `
${_esc(extra)}
` : ''}
${days != null ? days : '—'}
${days != null ? 'days remaining' : ''}
${_certBadgeTxt(s)} @@ -986,32 +985,26 @@ function _renderCerts(data) { }).join(''); } -function _loadCerts() { +function _loadCerts(onDone) { const grid = document.getElementById('vv-au-cert-grid'); grid.innerHTML = '
Loading…
'; - fetch(CERT_API) + fetch(CERT_API + '?action=npm') .then(r => r.json()) - .then(d => { if (d.ok) _renderCerts(d); }) - .catch(() => { grid.innerHTML = '
Failed to load cert data
'; }); + .then(d => { + if (d.ok) _renderNpmCerts(d); + else throw new Error(d.error || 'NPM error'); + if (onDone) onDone(); + }) + .catch(e => { + grid.innerHTML = `
${_esc(e.message || 'Failed to load')}
`; + if (onDone) onDone(); + }); } document.getElementById('vv-au-cert-run').addEventListener('click', function() { const btn = this; - const log = document.getElementById('vv-au-cert-log'); - btn.disabled = true; btn.textContent = 'Checking…'; - log.style.display = 'none'; log.textContent = ''; - const fd = new FormData(); fd.append('action', 'run'); - fetch(CERT_API, { method: 'POST', body: fd }) - .then(r => r.json()) - .then(d => { - btn.disabled = false; btn.textContent = 'Run now'; - if (d.data) _renderCerts(d.data); - if (d.output && d.output.length) { - log.style.display = 'block'; - log.textContent = d.output.join('\n').replace(/\x1b\[[0-9;]*m/g, ''); - } - }) - .catch(() => { btn.disabled = false; btn.textContent = 'Run now'; }); + btn.disabled = true; btn.textContent = 'Loading…'; + _loadCerts(() => { btn.disabled = false; btn.textContent = 'Refresh'; }); }); // ── Boot ────────────────────────────────────────────────────────────────────── diff --git a/Plugin/unraid/pages/rsync.php b/Plugin/unraid/pages/rsync.php index 0fdf653..33cf893 100644 --- a/Plugin/unraid/pages/rsync.php +++ b/Plugin/unraid/pages/rsync.php @@ -44,8 +44,7 @@ .vv-ry-run-st { text-align:right; } /* Settings */ -.vv-ry-set-grid { display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:6px; } -@media (max-width:900px) { .vv-ry-set-grid { grid-template-columns:repeat(2,1fr); } } +.vv-ry-set-grid { display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-top:6px; } .vv-ry-toggle { display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none; } .vv-ry-toggle-track { width:32px;height:18px;border-radius:9px;background:#222;border:1px solid #333;position:relative;transition:background .15s,border-color .15s;flex-shrink:0; } .vv-ry-toggle-track.on { background:#1a3a1a;border-color:#2d5a2d; } @@ -534,15 +533,6 @@ const PROF_COLORS = [ '#4dd0e1','#a5d6a7','#ff8a65','#90caf9','#f48fb1', ]; -function _rel(ts) { - if (!ts) return '—'; - const d = Math.floor(Date.now() / 1000) - ts; - if (d < 60) return 'just now'; - if (d < 3600) return Math.floor(d / 60) + 'm ago'; - if (d < 86400) return Math.floor(d / 3600) + 'h ' + Math.floor((d % 3600) / 60) + 'm ago'; - return Math.floor(d / 86400) + 'd ago'; -} - function _dur(s) { if (!s) return '—'; const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; @@ -551,14 +541,6 @@ function _dur(s) { return sec + 's'; } -function _fmtBytes(b) { - if (!b) return ''; - const G = 1073741824, M = 1048576; - if (b >= G) return (b / G).toFixed(1) + ' GB'; - if (b >= M) return (b / M).toFixed(0) + ' MB'; - return (b / 1024).toFixed(0) + ' KB'; -} - // ── Status + active card ────────────────────────────────────────────────────── function _statusCard(data) { const en = data.enabled; @@ -609,7 +591,7 @@ function _lastSyncCard(data) { ${WIN_META[k]?.label || k} ${e.status} - ${_rel(e.ts)} + ${_relTime(e.ts)} ${e.duration ? `${_dur(e.duration)}` : ''}
`; @@ -639,7 +621,7 @@ function _windowsRow(data) { if (ls && ls.ts) { const okCol = ls.status === 'success' ? '#4caf50' : '#ef5350'; statusHtml = `
${ls.status}
-
${_rel(ls.ts)}${ls.duration ? ' · ' + _dur(ls.duration) : ''}
`; +
${_relTime(ls.ts)}${ls.duration ? ' · ' + _dur(ls.duration) : ''}
`; } const expandable = !!(scripts.length || shares.length); @@ -755,7 +737,7 @@ function _settingsSection(data) { const mbit = s.bw_limit ? ' (~' + (s.bw_limit / 125).toFixed(0) + ' Mbit/s)' : ''; - return `
+ return `
Settings
diff --git a/Plugin/unraid/pages/watchdog.php b/Plugin/unraid/pages/watchdog.php index 3f6d882..0d1163d 100644 --- a/Plugin/unraid/pages/watchdog.php +++ b/Plugin/unraid/pages/watchdog.php @@ -55,19 +55,20 @@ const GB = 1073741824; -function _relTime(ts) { - if (!ts) return '—'; - const d = Math.floor(Date.now() / 1000) - ts; - if (d < 60) return 'just now'; - if (d < 3600) return Math.floor(d / 60) + 'm ago'; - if (d < 86400)return Math.floor(d / 3600) + 'h ' + Math.floor((d % 3600) / 60) + 'm ago'; - return Math.floor(d / 86400) + 'd ago'; +function _fmtBytes(b) { + if (!b) return '0'; + if (b >= GB) return (b / GB).toFixed(1) + 'G'; + if (b >= 1048576) return (b / 1048576).toFixed(0) + 'M'; + return (b / 1024).toFixed(0) + 'K'; } -function _fmtBytes(b) { - if (b >= GB) return (b / GB).toFixed(1) + ' GB'; - if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB'; - return (b / 1024).toFixed(0) + ' KB'; +function _relTime(ts) { + if (!ts) return '—'; + const s = Math.floor(Date.now() / 1000) - ts; + if (s < 60) return 'just now'; + if (s < 3600) return Math.floor(s / 60) + 'm ago'; + if (s < 86400) return Math.floor(s / 3600) + 'h ago'; + return Math.floor(s / 86400) + 'd ago'; } function _dur(s) { @@ -493,7 +494,10 @@ function vvWdLoad() { fetch('/plugins/varaverk/api/watchdog.php') .then(r => r.json()) .then(_render) - .catch(() => {}); + .catch(() => { + document.getElementById('vv-wd-grid').innerHTML = + '
Error loading watchdog data — check API
'; + }); } vvWdLoad(); diff --git a/Tools/claude_startup.sh b/Tools/claude_startup.sh index beac684..42cafaa 100755 --- a/Tools/claude_startup.sh +++ b/Tools/claude_startup.sh @@ -87,6 +87,17 @@ _log ".claude → $CLAUDE_DATA" ln -sfn "$CLAUDE_BIN" /root/.local/share/claude _log "claude binary → $CLAUDE_BIN" +# ── Symlink CLAUDE.md ───────────────────────────────────────────────────────────────────────── +# Lives on /boot so it survives reboots without appdata. Symlinked into /root so Claude +# picks it up automatically from the working directory on every session. +CLAUDE_MD="/boot/config/plugins/varaverk/CLAUDE.md" +if [[ -f "$CLAUDE_MD" ]]; then + ln -sfn "$CLAUDE_MD" /root/CLAUDE.md + _log "CLAUDE.md → $CLAUDE_MD" +else + _warn "CLAUDE.md not found at $CLAUDE_MD — skipping symlink" +fi + # ── Point the claude binary at the latest installed version ─────────────────────────────────── LATEST=$(ls "$CLAUDE_BIN/versions/" 2>/dev/null | sort -V | tail -1) if [[ -z "$LATEST" ]]; then