diff --git a/AppImage/ProxMenux-1.2.1.2-beta.AppImage b/AppImage/ProxMenux-1.2.1.3-beta.AppImage similarity index 83% rename from AppImage/ProxMenux-1.2.1.2-beta.AppImage rename to AppImage/ProxMenux-1.2.1.3-beta.AppImage index f084082a..492a79e4 100755 Binary files a/AppImage/ProxMenux-1.2.1.2-beta.AppImage and b/AppImage/ProxMenux-1.2.1.3-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 18c9eb2f..5bb0c461 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -37819f92b22f4860908f3f4dbe26f071f5c971e903e36ac3cf6e5fcdd9b162a7 ProxMenux-1.2.1.2-beta.AppImage +d825487696ecdf071bf9aaed58f4bcc3e5b2e44e51770b746a85a359d1d71794 diff --git a/AppImage/components/lxc-update-detection.tsx b/AppImage/components/lxc-update-detection.tsx new file mode 100644 index 00000000..08385ab6 --- /dev/null +++ b/AppImage/components/lxc-update-detection.tsx @@ -0,0 +1,227 @@ +"use client" + +import { useEffect, useState } from "react" +import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" +import { Badge } from "./ui/badge" +import { fetchApi } from "../lib/api-config" + +interface DetectionResponse { + success: boolean + enabled?: boolean + message?: string + purged?: number +} + +export function LxcUpdateDetection() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [enabled, setEnabled] = useState(true) + const [pending, setPending] = useState(true) + const [editMode, setEditMode] = useState(false) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + const [lastPurged, setLastPurged] = useState(null) + + useEffect(() => { + let cancelled = false + fetchApi("/api/lxc-updates/detection") + .then(data => { + if (cancelled) return + if (data.success && typeof data.enabled === "boolean") { + setEnabled(data.enabled) + setPending(data.enabled) + } else { + setError(data.message || "Failed to load setting") + } + }) + .catch(e => { + if (!cancelled) setError(String(e)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const hasChanges = pending !== enabled + + function handleEdit() { + setEditMode(true) + setError(null) + setSaved(false) + setLastPurged(null) + } + + function handleCancel() { + setPending(enabled) + setEditMode(false) + setError(null) + setLastPurged(null) + } + + async function handleSave() { + if (!hasChanges) { + setEditMode(false) + return + } + setSaving(true) + setError(null) + setSaved(false) + setLastPurged(null) + try { + const data = await fetchApi("/api/lxc-updates/detection", { + method: "POST", + body: JSON.stringify({ enabled: pending }), + }) + if (!data.success) { + setError(data.message || "Failed to save setting") + return + } + setEnabled(pending) + setEditMode(false) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + if (!pending && typeof data.purged === "number" && data.purged > 0) { + setLastPurged(data.purged) + } + // Notify the Notifications section so it hides/shows the + // lxc_updates_available toggle in real time. + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("proxmenux:lxc-detection-changed", { detail: { enabled: pending } }), + ) + } + } catch (e) { + setError(String(e)) + } finally { + setSaving(false) + } + } + + return ( + + +
+
+ + LXC Update Detection + {enabled ? ( + + Active + + ) : ( + + Disabled + + )} +
+
+ {saved && ( + + + Saved + + )} + {error && !editMode && ( + + Save failed: {error} + + )} + {editMode ? ( + <> + + + + ) : ( + + )} +
+
+ + Periodically check running Debian/Ubuntu/Alpine LXC containers for pending package updates + (apt list --upgradable / apk list -u) and surface them on the dashboard. The + corresponding notification toggle in Notifications → Services appears only while detection + is enabled. + +
+ + + {/* ── Enable/Disable ── */} +
+
+ +
+ Enable LXC update detection +

+ When OFF, ProxMenux stops scanning your CTs (no pct exec calls), removes existing LXC + entries from the managed-installs registry, and hides the related notification toggle. Default is + ON. +

+
+
+ +
+ + {lastPurged !== null && lastPurged > 0 && ( +
+ +

+ {lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the + next scan cycle. +

+
+ )} + + {error && editMode && ( +
+ +

{error}

+
+ )} +
+
+ ) +} diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index 5faa8f2f..de6076de 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -351,6 +351,12 @@ export function NotificationSettings() { error: string }>({ status: "idle", fallback_commands: [], error: "" }) const [systemHostname, setSystemHostname] = useState("") + // Mirrors the dedicated toggle from Settings → LXC Update Detection. + // When false, the per-event toggle for `lxc_updates_available` is hidden + // from every channel's category list (its DB preference is preserved). + // Updated on mount via fetch and on the fly via a CustomEvent dispatched + // by when the user flips the switch. + const [lxcDetectionEnabled, setLxcDetectionEnabled] = useState(true) // Load system hostname for display name placeholder const loadSystemHostname = useCallback(async () => { @@ -433,6 +439,43 @@ export function NotificationSettings() { loadSystemHostname() }, [loadConfig, loadStatus, loadSystemHostname]) + // Track the LXC update-detection toggle so we can conditionally hide + // the `lxc_updates_available` per-event toggle inside every channel's + // category list. Fetched once on mount; live updates ride on a custom + // event dispatched by whenever the user flips + // the switch upstream. + useEffect(() => { + let cancelled = false + fetchApi<{ success: boolean; enabled?: boolean }>("/api/lxc-updates/detection") + .then(data => { + if (cancelled) return + if (data.success && typeof data.enabled === "boolean") { + setLxcDetectionEnabled(data.enabled) + } + }) + .catch(() => { + // Default-true on fetch failure — matches the backend default and + // avoids hiding a notification toggle the user might rely on if + // the settings endpoint is transiently unreachable. + }) + + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail + if (detail && typeof detail.enabled === "boolean") { + setLxcDetectionEnabled(detail.enabled) + } + } + if (typeof window !== "undefined") { + window.addEventListener("proxmenux:lxc-detection-changed", handler) + } + return () => { + cancelled = true + if (typeof window !== "undefined") { + window.removeEventListener("proxmenux:lxc-detection-changed", handler) + } + } + }, []) + useEffect(() => { if (showHistory) loadHistory() }, [showHistory, loadHistory]) @@ -634,7 +677,16 @@ export function NotificationSettings() { {EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => { const isEnabled = overrides.categories[cat.key] ?? true const isExpanded = expandedCategories.has(`${chName}.${cat.key}`) - const eventsForGroup = evtByGroup[cat.key] || [] + // Hide the LXC update toggle when the user has disabled the + // dedicated detection setting upstream. The backend still + // returns the event type in the catalog (so its stored + // preference survives), but we filter it out of every + // channel's UI list so the operator never sees a notification + // toggle whose underlying scan is paused. + const rawEventsForGroup = evtByGroup[cat.key] || [] + const eventsForGroup = lxcDetectionEnabled + ? rawEventsForGroup + : rawEventsForGroup.filter(e => e.type !== "lxc_updates_available") const enabledCount = eventsForGroup.filter( e => (overrides.events?.[e.type] ?? e.default_enabled) ).length @@ -1779,14 +1831,23 @@ export function NotificationSettings() {
diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 129bfa8b..697733a6 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/ import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle } from "lucide-react" import { NotificationSettings } from "./notification-settings" import { HealthThresholds } from "./health-thresholds" +import { LxcUpdateDetection } from "./lxc-update-detection" import { ScriptTerminalModal } from "./script-terminal-modal" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Switch } from "./ui/switch" @@ -1194,6 +1195,12 @@ export function Settings() { values configured here drive what triggers the notifications below. */} + {/* LXC Update Detection — gates the per-CT apt/apk scan. When OFF, + the matching toggle in NotificationSettings is hidden (the + preference is preserved in the DB and reappears when detection + is re-enabled). */} + + {/* Notification Settings */} diff --git a/AppImage/package.json b/AppImage/package.json index 9576e699..f1858eb4 100644 --- a/AppImage/package.json +++ b/AppImage/package.json @@ -1,6 +1,6 @@ { "name": "ProxMenux-Monitor", - "version": "1.2.1.2-beta", + "version": "1.2.1.3-beta", "description": "Proxmox System Monitoring Dashboard", "private": true, "scripts": { diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 2753f9af..dbe77b47 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -10492,6 +10492,50 @@ def api_managed_installs_refresh(): return jsonify({'success': False, 'message': str(e)}), 500 +# ─── LXC Update Detection toggle ──────────────────────────────────────────── +# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt +# list --upgradable` scan entirely. The Notifications section keeps its own +# `lxc_updates_available` toggle (delivery only), but the UI hides it while +# detection is OFF — the underlying preference is preserved in the DB and +# re-appears when detection is flipped back ON. + +@app.route('/api/lxc-updates/detection', methods=['GET']) +@require_auth +def api_lxc_updates_detection_get(): + try: + import managed_installs + return jsonify({ + 'success': True, + 'enabled': managed_installs._lxc_updates_detection_enabled(), + }) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/lxc-updates/detection', methods=['POST']) +@require_auth +def api_lxc_updates_detection_set(): + try: + import managed_installs + data = request.get_json(silent=True) or {} + if 'enabled' not in data: + return jsonify({'success': False, 'message': 'Missing "enabled" field'}), 400 + enabled = bool(data['enabled']) + result = managed_installs.set_lxc_updates_detection_enabled(enabled) + if not result.get('ok'): + return jsonify({ + 'success': False, + 'message': result.get('error') or 'Failed to persist setting', + }), 500 + return jsonify({ + 'success': True, + 'enabled': enabled, + 'purged': result.get('purged', 0), + }) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + + @app.route('/api/health/thresholds', methods=['GET']) @require_auth def api_health_thresholds_get(): @@ -11624,20 +11668,31 @@ if __name__ == '__main__': # Try gevent with SSL for proper WebSocket (WSS) support try: from gevent import pywsgi - from geventwebsocket.handler import WebSocketHandler import ssl - + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(ssl_cert, ssl_key) - + print("[ProxMenux] Starting gevent server with SSL/WSS support...") + # IMPORTANT: do NOT pass `handler_class=WebSocketHandler` + # from geventwebsocket. flask-sock (the library wiring our + # /ws/terminal and /ws/script/ routes) already implements + # the WebSocket protocol on top of any standard WSGI server + # via `simple-websocket`. Stacking the geventwebsocket + # handler on top causes both layers to respond to the + # client's upgrade request — the server emits two + # `HTTP/1.1 101 Switching Protocols` headers back-to-back, + # which the browser interprets as a corrupt frame and + # closes with "WebSocket connection error" the moment the + # terminal modal opens. Using the default WSGIHandler lets + # flask-sock own the upgrade end-to-end. + # # `::` binds IPv6 + IPv4 (v4-mapped) on Linux when # net.ipv6.bindv6only=0 (the default). Issue #192 — IPv4-only # listening broke ProxMenux on dual-stack / v6-only hosts. server = pywsgi.WSGIServer( ('::', 8008), app, - handler_class=WebSocketHandler, ssl_context=ssl_context ) gevent_available = True diff --git a/AppImage/scripts/managed_installs.py b/AppImage/scripts/managed_installs.py index bfaef854..363ce37d 100644 --- a/AppImage/scripts/managed_installs.py +++ b/AppImage/scripts/managed_installs.py @@ -40,6 +40,7 @@ import datetime import json import os import re +import sqlite3 import subprocess import threading import time @@ -276,9 +277,14 @@ def _detect_oci_apps() -> list[dict]: # ── LXC containers (Phase 1: apt-based update detection) ──────────── # # Each running Debian/Ubuntu CT becomes a registry entry of type "lxc". -# Detection is opt-in: gated on the `lxc_updates_available` notification -# being enabled somewhere, so the heavy `pct exec` work doesn't run on -# hosts where the user hasn't asked for this. +# Detection is gated on a dedicated user setting (`lxc_updates.detection_enabled`, +# default ON) configured from Settings → LXC Update Detection. When the +# user flips it OFF, this detector returns [] and any existing type="lxc" +# entries in the registry are purged so the dashboard / API immediately +# stop reporting LXC update state. The notification toggle +# (`lxc_updates_available`) keeps its independent semantics — it only +# decides whether to deliver the notification when detection has actually +# produced new results. # # Phase 2 hook: once helper-scripts metadata is integrated, entries can # carry `_helper_script_app` so the checker swaps generic apt counting @@ -289,6 +295,96 @@ _PCT_BIN = "/usr/sbin/pct" _LXC_EXEC_TIMEOUT_SEC = 10 _LXC_OS_PROBE_TIMEOUT_SEC = 5 +# User-toggle storage. The setting lives in the same SQLite DB that +# notification_manager uses for user_settings, so we get atomic writes +# and the table is already created at startup by health_persistence. +_USER_SETTINGS_DB = "/usr/local/share/proxmenux/health_monitor.db" +_LXC_DETECTION_SETTING_KEY = "lxc_updates.detection_enabled" + + +def _lxc_updates_detection_enabled() -> bool: + """Read the dedicated detection toggle. Default True — existing + installs predating this setting keep their previous behaviour. + + Read failures (DB missing, locked, corrupt) also default True so a + transient DB problem never silently disables the feature. + """ + try: + if not os.path.exists(_USER_SETTINGS_DB): + return True + conn = sqlite3.connect(_USER_SETTINGS_DB, timeout=5) + try: + conn.execute("PRAGMA busy_timeout=2000") + row = conn.execute( + "SELECT setting_value FROM user_settings WHERE setting_key = ?", + (_LXC_DETECTION_SETTING_KEY,), + ).fetchone() + finally: + conn.close() + if row is None or row[0] is None: + return True + return str(row[0]).strip().lower() in ("1", "true", "yes", "on") + except Exception: + return True + + +def set_lxc_updates_detection_enabled(enabled: bool) -> dict: + """Persist the toggle. Returns ``{ok: bool, purged: int, error?: str}``. + + On OFF, also strip every ``type=lxc`` entry from the registry so the + dashboard and ``/api/managed-installs`` stop returning stale results + instantly — without waiting for the next 24h detection cycle. + """ + val = "true" if enabled else "false" + try: + conn = sqlite3.connect(_USER_SETTINGS_DB, timeout=10) + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + conn.execute( + "INSERT OR REPLACE INTO user_settings (setting_key, setting_value, updated_at) " + "VALUES (?, ?, ?)", + (_LXC_DETECTION_SETTING_KEY, val, _now_iso()), + ) + conn.commit() + finally: + conn.close() + except Exception as e: + return {"ok": False, "purged": 0, "error": str(e)} + + purged = 0 + if not enabled: + purged = _purge_lxc_entries_from_registry() + return {"ok": True, "purged": purged} + + +def _purge_lxc_entries_from_registry() -> int: + """Remove every type="lxc" entry from the registry. Returns the + count of entries removed. + + Used when the user disables LXC update detection — keeps the + on-disk state consistent with the toggle (zero stale LXC rows in + ``managed_installs.json``). + """ + try: + with _lock: + reg = _read_registry() + items = reg.get("items", []) + if not items: + return 0 + kept = [ + it for it in items + if not (isinstance(it, dict) and it.get("type") == "lxc") + ] + removed = len(items) - len(kept) + if removed > 0: + reg["items"] = kept + _write_registry(reg) + return removed + except Exception as e: + print(f"[managed_installs] failed to purge LXC entries: {e}") + return 0 + def _lxc_updates_notification_enabled() -> bool: """Return True if the user has enabled `lxc_updates_available` on @@ -382,13 +478,19 @@ def _detect_lxc_containers() -> list[dict]: family cached until the user resets the registry — acceptable trade-off vs paying the probe cost every 24h cycle. - Detection runs unconditionally so the dashboard always reflects - pending updates on running CTs. The `lxc_updates_available` - notification toggle only gates the *delivery* of the notification - (see _check_managed_installs_updates in notification_events.py), - not the detection — that keeps the toggle semantics consistent with - every other update stream (NVIDIA, Coral, post-install). + Detection respects the dedicated `lxc_updates.detection_enabled` + toggle (Settings → LXC Update Detection). When OFF, this returns [] + and the framework's removed_at logic clears any pre-existing CT + rows from the registry on the next run — the explicit purge in + ``set_lxc_updates_detection_enabled`` handles the immediate case. + + The notification toggle (`lxc_updates_available`) only gates the + *delivery* of the notification (see _check_managed_installs_updates + in notification_events.py), independently of this detection toggle. """ + if not _lxc_updates_detection_enabled(): + return [] + # Read existing registry so we can preserve cached `_os_family`. # No lock needed here — we only inspect; the framework holds the # write lock when it merges back our results in detect_and_register. @@ -860,13 +962,116 @@ def _run_pct_pkg_listing(vmid: str, cmd: str) -> tuple[bool, str, str]: return True, r.stdout, "" +# Refresh thresholds for the package-manager metadata cache. Threshold is +# 24h to match the rest of the check cycle: if a CT was last refreshed +# longer ago than that, we assume `apt list --upgradable` cannot reflect +# the upstream state and proactively refresh once before listing. +_LXC_CACHE_STALE_THRESHOLD_SEC = 24 * 3600 +_LXC_CACHE_REFRESH_TIMEOUT_SEC = 60 + + +def _refresh_lxc_pkg_cache_if_stale(vmid: str, family: str) -> dict: + """Best-effort refresh of the CT's package-manager metadata cache. + + If the local cache is older than ``_LXC_CACHE_STALE_THRESHOLD_SEC``, + run ``apt-get update`` / ``apk update`` from outside the CT once + before the upgradable listing. Any failure (no network, broken + repo, timeout) is swallowed silently — the listing below still + runs against whatever cache exists, so the detector can never make + the situation worse than the pre-existing CT state. + + Returns a small diagnostics dict consumed by ``_check_lxc_updates`` + to populate ``_cache_age_seconds`` / ``_cache_refreshed`` on the + registry entry (visible in the dashboard / managed-installs API). + """ + if family in ("debian", "ubuntu"): + # apt's authoritative timestamp is the mtime of pkgcache.bin, + # which `apt-get update` rewrites on every successful run. + # We `printf %Y` to get the mtime as a unix timestamp and `|| + # echo 0` so a missing file (fresh CT, broken state) is treated + # as infinitely old and triggers the refresh. + cmd_age = "stat -c '%Y' /var/cache/apt/pkgcache.bin 2>/dev/null || echo 0" + cmd_refresh = "apt-get update -qq" + elif family == "alpine": + # apk writes index files under /var/lib/apk/. The + # `installed` file timestamp moves on package installs, but + # `apk update` rewrites the cached APKINDEX bundles under + # /var/cache/apk/*.tar.gz — take the newest mtime there as + # the authoritative "last update" marker. If the cache dir + # doesn't exist (apk default with caching disabled), fall + # back to the index files in /etc/apk/. + cmd_age = ( + "ls -t /var/cache/apk/*.tar.gz 2>/dev/null | head -1 " + "| xargs -r stat -c '%Y' 2>/dev/null " + "|| stat -c '%Y' /etc/apk/world 2>/dev/null || echo 0" + ) + cmd_refresh = "apk update" + else: + return {"refreshed": False, "was_stale": False, "cache_age_seconds": None, "error": None} + + ok, stdout, _ = _run_pct_pkg_listing(vmid, cmd_age) + if not ok: + return {"refreshed": False, "was_stale": False, "cache_age_seconds": None, "error": "stat failed"} + try: + # Use the last numeric line in case the command emitted stderr + # noise that snuck into stdout (e.g. some shells route warnings). + cache_mtime = 0 + for ln in stdout.strip().splitlines(): + try: + cache_mtime = int(ln.strip()) + break + except ValueError: + continue + except Exception: + cache_mtime = 0 + + now = int(time.time()) + cache_age = (now - cache_mtime) if cache_mtime > 0 else None + was_stale = cache_age is None or cache_age > _LXC_CACHE_STALE_THRESHOLD_SEC + + if not was_stale: + return { + "refreshed": False, "was_stale": False, + "cache_age_seconds": cache_age, "error": None, + } + + try: + r = subprocess.run( + [_PCT_BIN, "exec", vmid, "--", "sh", "-c", cmd_refresh], + capture_output=True, text=True, + timeout=_LXC_CACHE_REFRESH_TIMEOUT_SEC, + ) + if r.returncode == 0: + return { + "refreshed": True, "was_stale": True, + "cache_age_seconds": cache_age, "error": None, + } + return { + "refreshed": False, "was_stale": True, + "cache_age_seconds": cache_age, + "error": (r.stderr or "refresh failed").strip()[:200], + } + except subprocess.TimeoutExpired: + return { + "refreshed": False, "was_stale": True, + "cache_age_seconds": cache_age, "error": "refresh timed out", + } + except (FileNotFoundError, OSError) as e: + return { + "refreshed": False, "was_stale": True, + "cache_age_seconds": cache_age, "error": str(e), + } + + def _check_lxc_updates(entry: dict) -> dict: """Inspect pending package updates inside the LXC and report them. Dispatches to the right package-manager parser based on the cached - ``_os_family``. Uses the CT's existing metadata cache — never runs - ``apt update`` / ``apk update`` from outside, so the user's own - update cadence (unattended-upgrades, cron) is preserved. + ``_os_family``. If the CT's local apt/apk metadata cache is older + than 24h, runs a best-effort refresh first via + ``_refresh_lxc_pkg_cache_if_stale`` — without this, CTs that no + one ever runs ``apt update`` in (long-running appliances) report + 0 pending updates even when upstream has hundreds queued. The dedup fingerprint (``latest``) combines count, security count and the sorted top package names so a stable set of pending @@ -881,6 +1086,8 @@ def _check_lxc_updates(entry: dict) -> dict: "last_check": _now_iso(), "error": "no vmid in entry", } + refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family) + if family in ("debian", "ubuntu"): ok, stdout, err = _run_pct_pkg_listing( vmid, "apt list --upgradable 2>/dev/null" @@ -920,6 +1127,9 @@ def _check_lxc_updates(entry: dict) -> dict: "_count": count, "_security_count": sec_count, "_packages": packages[:30], # cap to keep the registry compact + "_cache_age_seconds": refresh_diag.get("cache_age_seconds"), + "_cache_refreshed": refresh_diag.get("refreshed"), + "_cache_refresh_error": refresh_diag.get("error"), } diff --git a/beta_version.txt b/beta_version.txt index ff81ff4f..5a81cb25 100644 --- a/beta_version.txt +++ b/beta_version.txt @@ -1 +1 @@ -1.2.1.2 +1.2.1.3